@andypai/agent-kanban 0.3.4 → 0.3.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/README.md CHANGED
@@ -62,21 +62,26 @@ Running `kanban` with no arguments is equivalent to `kanban board view`.
62
62
 
63
63
  All operations route through a provider backend. Set `KANBAN_PROVIDER` to choose one.
64
64
 
65
- | Variable | Default | Description |
66
- | ------------------------- | ------------- | ------------------------------------------------------------------------ |
67
- | `KANBAN_PROVIDER` | `local` | `local`, `linear`, or `jira` |
68
- | `KANBAN_DB_PATH` | auto-resolved | SQLite database path |
69
- | `KANBAN_SYNC_INTERVAL_MS` | `30000` | Polling sync interval for remote providers; integer milliseconds >= 1000 |
70
- | `LINEAR_API_KEY` | | Required when `KANBAN_PROVIDER=linear` |
71
- | `LINEAR_TEAM_ID` | — | Required when `KANBAN_PROVIDER=linear` |
72
- | `JIRA_BASE_URL` | — | Required when `KANBAN_PROVIDER=jira` (e.g. `https://acme.atlassian.net`) |
73
- | `JIRA_EMAIL` | | Required when `KANBAN_PROVIDER=jira` (Atlassian account email) |
74
- | `JIRA_API_TOKEN` | — | Required when `KANBAN_PROVIDER=jira` (Atlassian API token) |
75
- | `JIRA_PROJECT_KEY` | — | Required when `KANBAN_PROVIDER=jira` (e.g. `ENG`) |
76
- | `JIRA_BOARD_ID` | — | Optional when `KANBAN_PROVIDER=jira` (Agile board id for column order) |
77
- | `JIRA_ISSUE_TYPE` | `Task` | Optional when `KANBAN_PROVIDER=jira` (default issue type for new tasks) |
78
-
79
- Without `KANBAN_DB_PATH`, the local provider resolves the database in this order:
65
+ | Variable | Default | Description |
66
+ | ---------------------------- | ------------- | ------------------------------------------------------------------------ |
67
+ | `KANBAN_PROVIDER` | `local` | `local`, `linear`, or `jira` |
68
+ | `KANBAN_STORAGE` | `sqlite` | `sqlite` or `postgres` |
69
+ | `KANBAN_DATABASE_URL` | | Required when `KANBAN_STORAGE=postgres` |
70
+ | `KANBAN_DB_PATH` | auto-resolved | SQLite database path |
71
+ | `KANBAN_DEFAULT_COLUMNS` | — | Optional bootstrap column names for local/Postgres caches |
72
+ | `KANBAN_DEFAULT_TASK_COLUMN` | — | Optional created-task column override for local/Postgres caches |
73
+ | `KANBAN_SYNC_INTERVAL_MS` | `30000` | Polling sync interval for remote providers; integer milliseconds >= 1000 |
74
+ | `LINEAR_API_KEY` | — | Required when `KANBAN_PROVIDER=linear` |
75
+ | `LINEAR_TEAM_ID` | — | Required when `KANBAN_PROVIDER=linear` |
76
+ | `JIRA_BASE_URL` | — | Required when `KANBAN_PROVIDER=jira` (e.g. `https://acme.atlassian.net`) |
77
+ | `JIRA_EMAIL` | | Required when `KANBAN_PROVIDER=jira` (Atlassian account email) |
78
+ | `JIRA_API_TOKEN` | — | Required when `KANBAN_PROVIDER=jira` (Atlassian API token) |
79
+ | `JIRA_PROJECT_KEY` | — | Required when `KANBAN_PROVIDER=jira` (e.g. `ENG`) |
80
+ | `JIRA_BOARD_ID` | — | Optional when `KANBAN_PROVIDER=jira` (Agile board id for column order) |
81
+ | `JIRA_ISSUE_TYPE` | `Task` | Optional when `KANBAN_PROVIDER=jira` (default issue type for new tasks) |
82
+
83
+ When `KANBAN_STORAGE=sqlite` and `KANBAN_DB_PATH` is unset, the local provider
84
+ resolves the database in this order:
80
85
 
81
86
  1. `./.kanban/board.db` if it exists in the current working directory
82
87
  2. `~/.kanban/board.db` if it exists
@@ -246,6 +251,7 @@ Default columns: `recurring`, `backlog`, `in-progress`, `review`, `done`.
246
251
  ```bash
247
252
  kanban serve # default port 3000
248
253
  kanban serve --port 8080
254
+ kanban serve --sync-interval-ms 300000
249
255
  kanban serve --tunnel # optional public URL for webhook testing
250
256
  ```
251
257
 
@@ -301,8 +307,9 @@ Starts a Bun HTTP server with:
301
307
  - **Readiness check** at `/api/ready` — reports whether the cache has warmed at least once
302
308
  - **Sync status** at `/api/sync-status` — reports background sync state plus provider sync metadata
303
309
 
304
- In `serve` mode, remote providers now warm once on startup and continue syncing
305
- in the background every `KANBAN_SYNC_INTERVAL_MS` milliseconds. Full
310
+ In `serve` mode, remote providers warm once on startup and continue syncing in
311
+ the background on the runtime's configured polling cadence. Use
312
+ `KANBAN_SYNC_INTERVAL_MS` or `kanban serve --sync-interval-ms` to tune it. Full
306
313
  reconciliation is still handled by the provider-specific logic on top of that
307
314
  steady cadence.
308
315
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andypai/agent-kanban",
3
- "version": "0.3.4",
3
+ "version": "0.3.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": {
@@ -55,7 +55,8 @@
55
55
  "prepack": "cd ui && bun install --frozen-lockfile && bun run build"
56
56
  },
57
57
  "dependencies": {
58
- "@modelcontextprotocol/sdk": "^1.29.0"
58
+ "@modelcontextprotocol/sdk": "^1.29.0",
59
+ "postgres": "^3.4.4"
59
60
  },
60
61
  "devDependencies": {
61
62
  "@eslint/js": "^9.39.1",
@@ -3,17 +3,17 @@ import { Database } from 'bun:sqlite'
3
3
  import { initSchema, seedDefaultColumns, addTask } from '../db'
4
4
  import { handleRequest } from '../api'
5
5
  import { createProvider } from '../providers/index'
6
+ import type { KanbanProvider } from '../providers/types'
6
7
 
7
8
  let db: Database
8
- let provider: ReturnType<typeof createProvider>
9
+ let provider: KanbanProvider
9
10
 
10
11
  beforeEach(() => {
11
- process.env['KANBAN_PROVIDER'] = 'local'
12
12
  db = new Database(':memory:')
13
13
  db.run('PRAGMA foreign_keys = ON')
14
14
  initSchema(db)
15
15
  seedDefaultColumns(db)
16
- provider = createProvider(db, ':memory:')
16
+ provider = createProvider(db, { provider: 'local' }, ':memory:')
17
17
  })
18
18
 
19
19
  describe('handleRequest', () => {
@@ -59,8 +59,28 @@ describe('parseServeArgs', () => {
59
59
  })
60
60
 
61
61
  test('--tunnel opts in; --port and --db override', () => {
62
- const opts = parseServeArgs(['serve', '--tunnel', '--port', '5050', '--db', '/tmp/b.db'])
63
- expect(opts).toEqual({ db: '/tmp/b.db', port: 5050, tunnel: true })
62
+ const opts = parseServeArgs([
63
+ 'serve',
64
+ '--tunnel',
65
+ '--port',
66
+ '5050',
67
+ '--db',
68
+ '/tmp/b.db',
69
+ '--sync-interval-ms',
70
+ '600000',
71
+ ])
72
+ expect(opts).toEqual({
73
+ db: '/tmp/b.db',
74
+ port: 5050,
75
+ syncIntervalMs: 600_000,
76
+ tunnel: true,
77
+ })
78
+ })
79
+
80
+ test('--sync-interval-ms rejects invalid values', () => {
81
+ for (const raw of ['999', '0']) {
82
+ expect(() => parseServeArgs(['serve', '--sync-interval-ms', raw])).toThrow(KanbanError)
83
+ }
64
84
  })
65
85
  })
66
86
 
@@ -6,6 +6,7 @@ import { join } from 'node:path'
6
6
  import { ErrorCode, KanbanError } from '../errors'
7
7
  import { run } from '../index'
8
8
  import { createProvider } from '../providers/index'
9
+ import { trackerConfigFromEnv } from '../tracker-config'
9
10
 
10
11
  const ENV_KEYS = [
11
12
  'KANBAN_PROVIDER',
@@ -17,6 +18,7 @@ const ENV_KEYS = [
17
18
  'JIRA_ISSUE_TYPE',
18
19
  'LINEAR_API_KEY',
19
20
  'LINEAR_TEAM_ID',
21
+ 'KANBAN_SYNC_INTERVAL_MS',
20
22
  ] as const
21
23
 
22
24
  const tempRoot = mkdtempSync(join(tmpdir(), 'jira-wiring-'))
@@ -75,17 +77,16 @@ describe('jira-wiring', () => {
75
77
  }
76
78
  })
77
79
 
78
- test('createProvider throws PROVIDER_NOT_CONFIGURED listing missing JIRA_API_TOKEN', () => {
79
- const { db, dbPath } = makeDb()
80
+ test('trackerConfigFromEnv throws PROVIDER_NOT_CONFIGURED listing missing JIRA_API_TOKEN', () => {
80
81
  process.env['KANBAN_PROVIDER'] = 'jira'
81
82
  process.env['JIRA_BASE_URL'] = 'https://example.atlassian.net'
82
83
  process.env['JIRA_EMAIL'] = 'a@example.com'
83
84
  delete process.env['JIRA_API_TOKEN']
84
85
  process.env['JIRA_PROJECT_KEY'] = 'ENG'
85
86
 
86
- expect(() => createProvider(db, dbPath)).toThrow(KanbanError)
87
+ expect(() => trackerConfigFromEnv()).toThrow(KanbanError)
87
88
  try {
88
- createProvider(db, dbPath)
89
+ trackerConfigFromEnv()
89
90
  } catch (err) {
90
91
  expect((err as KanbanError).code).toBe(ErrorCode.PROVIDER_NOT_CONFIGURED)
91
92
  expect((err as Error).message).toContain('JIRA_API_TOKEN')
@@ -93,8 +94,7 @@ describe('jira-wiring', () => {
93
94
  }
94
95
  })
95
96
 
96
- test('createProvider throws PROVIDER_NOT_CONFIGURED listing multiple missing vars', () => {
97
- const { db, dbPath } = makeDb()
97
+ test('trackerConfigFromEnv throws PROVIDER_NOT_CONFIGURED listing multiple missing vars', () => {
98
98
  process.env['KANBAN_PROVIDER'] = 'jira'
99
99
  process.env['JIRA_BASE_URL'] = 'https://example.atlassian.net'
100
100
  delete process.env['JIRA_EMAIL']
@@ -104,7 +104,7 @@ describe('jira-wiring', () => {
104
104
  let msg = ''
105
105
  let code: string | undefined
106
106
  try {
107
- createProvider(db, dbPath)
107
+ trackerConfigFromEnv()
108
108
  } catch (err) {
109
109
  msg = (err as Error).message
110
110
  code = (err as KanbanError).code
@@ -118,17 +118,42 @@ describe('jira-wiring', () => {
118
118
 
119
119
  test('createProvider builds JiraProvider when all four required vars are set', () => {
120
120
  const { db, dbPath } = makeDb()
121
- setJiraRequiredEnv()
122
- const provider = createProvider(db, dbPath)
121
+ const provider = createProvider(
122
+ db,
123
+ {
124
+ provider: 'jira',
125
+ baseUrl: 'https://example.atlassian.net',
126
+ email: 'a@example.com',
127
+ apiToken: 'tok-test',
128
+ projectKey: 'ENG',
129
+ },
130
+ dbPath,
131
+ )
123
132
  expect(provider.type).toBe('jira')
124
133
  })
125
134
 
126
- test('JIRA_BOARD_ID non-numeric falls back to undefined', () => {
127
- const { db, dbPath } = makeDb()
135
+ test('JIRA_BOARD_ID non-numeric falls back to undefined in env loader', () => {
128
136
  setJiraRequiredEnv()
129
137
  process.env['JIRA_BOARD_ID'] = 'notanumber'
130
- const provider = createProvider(db, dbPath)
131
- expect(provider.type).toBe('jira')
138
+ const config = trackerConfigFromEnv()
139
+ expect(config.provider).toBe('jira')
140
+ expect(config).not.toHaveProperty('boardId')
141
+ })
142
+
143
+ test('trackerConfigFromEnv carries remote polling sync interval', () => {
144
+ setJiraRequiredEnv()
145
+ process.env['KANBAN_SYNC_INTERVAL_MS'] = '60000'
146
+
147
+ const jiraConfig = trackerConfigFromEnv()
148
+ expect(jiraConfig.provider).toBe('jira')
149
+ expect(jiraConfig.syncIntervalMs).toBe(60_000)
150
+
151
+ process.env['KANBAN_PROVIDER'] = 'linear'
152
+ process.env['LINEAR_API_KEY'] = 'lin_api_test'
153
+ process.env['LINEAR_TEAM_ID'] = 'team-test'
154
+ const linearConfig = trackerConfigFromEnv()
155
+ expect(linearConfig.provider).toBe('linear')
156
+ expect(linearConfig.syncIntervalMs).toBe(60_000)
132
157
  })
133
158
 
134
159
  test('kanban column add under KANBAN_PROVIDER=jira exits with UNSUPPORTED_OPERATION', async () => {
@@ -178,10 +203,15 @@ describe('jira-wiring', () => {
178
203
 
179
204
  test('KANBAN_PROVIDER=linear still builds LinearProvider (regression guard)', () => {
180
205
  const { db, dbPath } = makeDb()
181
- process.env['KANBAN_PROVIDER'] = 'linear'
182
- process.env['LINEAR_API_KEY'] = 'lin_api_test'
183
- process.env['LINEAR_TEAM_ID'] = 'team-test'
184
- const provider = createProvider(db, dbPath)
206
+ const provider = createProvider(
207
+ db,
208
+ {
209
+ provider: 'linear',
210
+ apiKey: 'lin_api_test',
211
+ teamId: 'team-test',
212
+ },
213
+ dbPath,
214
+ )
185
215
  expect(provider.type).toBe('linear')
186
216
  })
187
217
  })
@@ -0,0 +1,315 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
2
+ import postgres from 'postgres'
3
+
4
+ import { run } from '../index'
5
+ import type { Task } from '../types'
6
+
7
+ const databaseUrl = process.env['KANBAN_PG_TEST_URL'] ?? process.env['DATABASE_URL']
8
+ const pgTest = databaseUrl ? test : test.skip
9
+
10
+ type FetchInit = RequestInit | undefined
11
+ type StubCall = { url: string; init?: FetchInit }
12
+ type StubHandler = (url: string, init?: FetchInit) => Response | Promise<Response>
13
+ type StubRoute = { match: (url: string) => boolean; handler: StubHandler }
14
+
15
+ function jiraFetchStub(routes: StubRoute[]): {
16
+ fn: typeof fetch
17
+ calls: StubCall[]
18
+ } {
19
+ const calls: StubCall[] = []
20
+ const fn = (async (input: string | URL | Request, init?: FetchInit) => {
21
+ const url =
22
+ typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url
23
+ calls.push({ url, init })
24
+ for (const route of routes) {
25
+ if (route.match(url)) return route.handler(url, init)
26
+ }
27
+ return new Response('route not stubbed: ' + url, { status: 500 })
28
+ }) as unknown as typeof fetch
29
+ return { fn, calls }
30
+ }
31
+
32
+ function jsonResponse(body: unknown, status = 200): Response {
33
+ return new Response(JSON.stringify(body), {
34
+ status,
35
+ headers: { 'content-type': 'application/json' },
36
+ })
37
+ }
38
+
39
+ const projectFixture = { id: '10000', key: 'ENG', name: 'Engineering' }
40
+
41
+ function makeIssue(
42
+ overrides: Partial<{
43
+ id: string
44
+ key: string
45
+ summary: string
46
+ updated: string
47
+ }> = {},
48
+ ): Record<string, unknown> {
49
+ return {
50
+ id: overrides.id ?? '10001',
51
+ key: overrides.key ?? 'ENG-1',
52
+ fields: {
53
+ summary: overrides.summary ?? 'Postgres cached Jira issue',
54
+ description: null,
55
+ status: { id: '10', name: 'To Do' },
56
+ issuetype: { id: '10000', name: 'Task' },
57
+ priority: { id: '2', name: 'High' },
58
+ assignee: { accountId: 'a1', displayName: 'Alice' },
59
+ labels: ['garage'],
60
+ comment: { total: 0 },
61
+ created: '2026-01-01T00:00:00Z',
62
+ updated: overrides.updated ?? '2026-01-02T00:00:00Z',
63
+ project: { id: '10000', key: 'ENG' },
64
+ },
65
+ }
66
+ }
67
+
68
+ function standardRoutes(): StubRoute[] {
69
+ const issues = [makeIssue()]
70
+ const comments: Record<
71
+ string,
72
+ Array<{ id: string; body: unknown; created: string; updated: string }>
73
+ > = {}
74
+ const setIssueStatus = (issueKey: string, statusId: string, name: string): void => {
75
+ const issue = issues.find((candidate) => String(candidate.key) === issueKey) as
76
+ | { fields?: { status?: { id: string; name: string }; updated?: string } }
77
+ | undefined
78
+ if (!issue?.fields?.status) return
79
+ issue.fields.status = { id: statusId, name }
80
+ issue.fields.updated = '2026-01-06T00:00:00Z'
81
+ }
82
+ return [
83
+ {
84
+ match: (url) => url.includes('/rest/api/3/project/ENG/statuses'),
85
+ handler: () =>
86
+ jsonResponse([
87
+ {
88
+ id: 'cat-1',
89
+ name: 'To Do',
90
+ statuses: [
91
+ { id: '10', name: 'To Do' },
92
+ { id: '20', name: 'Done' },
93
+ ],
94
+ },
95
+ ]),
96
+ },
97
+ {
98
+ match: (url) => url.includes('/rest/api/3/project/ENG'),
99
+ handler: () => jsonResponse(projectFixture),
100
+ },
101
+ {
102
+ match: (url) => url.includes('/rest/api/3/user/assignable/search'),
103
+ handler: () => jsonResponse([{ accountId: 'a1', displayName: 'Alice', active: true }]),
104
+ },
105
+ {
106
+ match: (url) => url.includes('/rest/api/3/priority'),
107
+ handler: () => jsonResponse([{ id: '2', name: 'High' }]),
108
+ },
109
+ {
110
+ match: (url) => url.includes('/rest/api/3/issuetype/project'),
111
+ handler: () => jsonResponse([{ id: '10000', name: 'Task' }]),
112
+ },
113
+ {
114
+ match: (url) => url.endsWith('/rest/api/3/issue'),
115
+ handler: async (_url, init) => {
116
+ const body = JSON.parse(String(init?.body ?? '{}')) as {
117
+ fields?: { summary?: string }
118
+ }
119
+ const issue = makeIssue({
120
+ id: '10002',
121
+ key: 'ENG-2',
122
+ summary: body.fields?.summary ?? 'Created issue',
123
+ updated: '2026-01-03T00:00:00Z',
124
+ })
125
+ issues.push(issue)
126
+ return jsonResponse(
127
+ { id: '10002', key: 'ENG-2', self: 'https://example/rest/api/3/issue/10002' },
128
+ 201,
129
+ )
130
+ },
131
+ },
132
+ {
133
+ match: (url) => /\/rest\/api\/3\/issue\/ENG-\d+\/comment$/.test(new URL(url).pathname),
134
+ handler: async (url, init) => {
135
+ const issueKey = new URL(url).pathname.match(/\/issue\/(ENG-\d+)\/comment/)![1]!
136
+ if (init?.method === 'POST') {
137
+ const body = JSON.parse(String(init.body ?? '{}')) as { body?: unknown }
138
+ const row = {
139
+ id: `comment-${(comments[issueKey]?.length ?? 0) + 1}`,
140
+ body: body.body,
141
+ created: '2026-01-04T00:00:00Z',
142
+ updated: '2026-01-04T00:00:00Z',
143
+ }
144
+ comments[issueKey] = [...(comments[issueKey] ?? []), row]
145
+ return jsonResponse(row, 201)
146
+ }
147
+ return jsonResponse({
148
+ startAt: 0,
149
+ maxResults: 100,
150
+ total: comments[issueKey]?.length ?? 0,
151
+ comments: comments[issueKey] ?? [],
152
+ })
153
+ },
154
+ },
155
+ {
156
+ match: (url) =>
157
+ /\/rest\/api\/3\/issue\/ENG-\d+\/comment\/comment-\d+$/.test(new URL(url).pathname),
158
+ handler: async (url, init) => {
159
+ const [, issueKey, commentId] = new URL(url).pathname.match(
160
+ /\/issue\/(ENG-\d+)\/comment\/(comment-\d+)$/,
161
+ )!
162
+ const rows = comments[issueKey!] ?? []
163
+ const existing = rows.find((row) => row.id === commentId)
164
+ if (!existing) return jsonResponse({ errorMessages: ['missing'] }, 404)
165
+ if (init?.method === 'PUT') {
166
+ const body = JSON.parse(String(init.body ?? '{}')) as { body?: unknown }
167
+ existing.body = body.body
168
+ existing.updated = '2026-01-05T00:00:00Z'
169
+ }
170
+ return jsonResponse(existing)
171
+ },
172
+ },
173
+ {
174
+ match: (url) => /\/rest\/api\/3\/issue\/ENG-\d+\/transitions$/.test(new URL(url).pathname),
175
+ handler: async (url, init) => {
176
+ const issueKey = new URL(url).pathname.match(/\/issue\/(ENG-\d+)\/transitions$/)![1]!
177
+ if (init?.method === 'POST') {
178
+ setIssueStatus(issueKey, '20', 'Done')
179
+ return new Response(null, { status: 204 })
180
+ }
181
+ return jsonResponse({
182
+ transitions: [{ id: 'move-done', name: 'Done', to: { id: '20', name: 'Done' } }],
183
+ })
184
+ },
185
+ },
186
+ {
187
+ match: (url) => /\/rest\/api\/3\/issue\/[^/]+\/changelog/.test(url),
188
+ handler: () =>
189
+ jsonResponse({
190
+ startAt: 0,
191
+ maxResults: 100,
192
+ total: 0,
193
+ isLast: true,
194
+ values: [],
195
+ }),
196
+ },
197
+ {
198
+ match: (url) => url.includes('/rest/api/3/search/jql'),
199
+ handler: () =>
200
+ jsonResponse({
201
+ startAt: 0,
202
+ maxResults: 100,
203
+ total: issues.length,
204
+ issues,
205
+ }),
206
+ },
207
+ ]
208
+ }
209
+
210
+ function expectOk<T>(result: Awaited<ReturnType<typeof run>>): T {
211
+ expect(result.exitCode).toBe(0)
212
+ expect(result.output.ok).toBe(true)
213
+ if (!result.output.ok) throw new Error('expected successful CLI output')
214
+ return result.output.data as T
215
+ }
216
+
217
+ describe('postgres jira provider', () => {
218
+ let previousEnv: Record<string, string | undefined>
219
+ let previousFetch: typeof fetch
220
+ let sql: postgres.Sql | null = null
221
+
222
+ beforeEach(async () => {
223
+ previousFetch = globalThis.fetch
224
+ previousEnv = {
225
+ KANBAN_STORAGE: process.env['KANBAN_STORAGE'],
226
+ KANBAN_DATABASE_URL: process.env['KANBAN_DATABASE_URL'],
227
+ KANBAN_PROVIDER: process.env['KANBAN_PROVIDER'],
228
+ JIRA_BASE_URL: process.env['JIRA_BASE_URL'],
229
+ JIRA_EMAIL: process.env['JIRA_EMAIL'],
230
+ JIRA_API_TOKEN: process.env['JIRA_API_TOKEN'],
231
+ JIRA_PROJECT_KEY: process.env['JIRA_PROJECT_KEY'],
232
+ }
233
+
234
+ process.env['KANBAN_STORAGE'] = 'postgres'
235
+ process.env['KANBAN_DATABASE_URL'] = databaseUrl
236
+ process.env['KANBAN_PROVIDER'] = 'jira'
237
+ process.env['JIRA_BASE_URL'] = 'https://example.atlassian.net'
238
+ process.env['JIRA_EMAIL'] = 'user@example.com'
239
+ process.env['JIRA_API_TOKEN'] = 'token'
240
+ process.env['JIRA_PROJECT_KEY'] = 'ENG'
241
+
242
+ if (databaseUrl) {
243
+ sql = postgres(databaseUrl, { max: 1, onnotice: () => {} })
244
+ await sql`DROP TABLE IF EXISTS jira_activity`
245
+ await sql`DROP TABLE IF EXISTS jira_issues`
246
+ await sql`DROP TABLE IF EXISTS jira_issue_types`
247
+ await sql`DROP TABLE IF EXISTS jira_priorities`
248
+ await sql`DROP TABLE IF EXISTS jira_users`
249
+ await sql`DROP TABLE IF EXISTS jira_columns`
250
+ await sql`DROP TABLE IF EXISTS jira_sync_meta`
251
+ }
252
+
253
+ globalThis.fetch = jiraFetchStub(standardRoutes()).fn
254
+ })
255
+
256
+ afterEach(async () => {
257
+ globalThis.fetch = previousFetch
258
+ if (sql) {
259
+ await sql.end({ timeout: 1 })
260
+ sql = null
261
+ }
262
+ for (const [key, value] of Object.entries(previousEnv)) {
263
+ if (value === undefined) delete process.env[key]
264
+ else process.env[key] = value
265
+ }
266
+ })
267
+
268
+ pgTest('lists Jira tasks from a shared Postgres cache through the CLI path', async () => {
269
+ const tasks = expectOk<Task[]>(await run(['task', 'list', '-c', 'To Do']))
270
+
271
+ expect(tasks).toHaveLength(1)
272
+ expect(tasks[0]).toMatchObject({
273
+ id: 'jira:10001',
274
+ externalRef: 'ENG-1',
275
+ title: 'Postgres cached Jira issue',
276
+ priority: 'high',
277
+ assignee: 'Alice',
278
+ project: 'ENG',
279
+ })
280
+ })
281
+
282
+ pgTest('creates Jira tasks and writes comments through Postgres storage', async () => {
283
+ const created = expectOk<Task>(
284
+ await run(['task', 'add', 'Created through Postgres Jira', '-p', 'high', '-a', 'Alice']),
285
+ )
286
+
287
+ expect(created).toMatchObject({
288
+ id: 'jira:10002',
289
+ externalRef: 'ENG-2',
290
+ title: 'Created through Postgres Jira',
291
+ priority: 'high',
292
+ assignee: 'Alice',
293
+ })
294
+
295
+ const comment = expectOk(await run(['comment', 'add', 'ENG-2', 'Garage projection comment']))
296
+ expect(comment).toMatchObject({
297
+ id: 'comment-1',
298
+ task_id: 'jira:10002',
299
+ body: 'Garage projection comment',
300
+ })
301
+
302
+ const comments = expectOk(await run(['comment', 'list', 'ENG-2']))
303
+ expect(comments).toHaveLength(1)
304
+ })
305
+
306
+ pgTest('moves Jira tasks through Postgres storage', async () => {
307
+ const moved = expectOk<Task>(await run(['task', 'move', 'ENG-1', 'Done']))
308
+
309
+ expect(moved).toMatchObject({
310
+ id: 'jira:10001',
311
+ externalRef: 'ENG-1',
312
+ column_id: '20',
313
+ })
314
+ })
315
+ })