@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 +24 -17
- package/package.json +3 -2
- package/src/__tests__/api.test.ts +3 -3
- package/src/__tests__/index.test.ts +22 -2
- package/src/__tests__/jira-wiring.test.ts +47 -17
- package/src/__tests__/postgres-jira-provider.test.ts +315 -0
- package/src/__tests__/postgres-linear-provider.test.ts +309 -0
- package/src/__tests__/postgres-local-provider.test.ts +129 -0
- package/src/__tests__/storage-config.test.ts +39 -0
- package/src/index.ts +65 -28
- package/src/provider-runtime.ts +110 -0
- package/src/providers/index.ts +16 -39
- package/src/providers/jira.ts +2 -2
- package/src/providers/linear.ts +2 -2
- package/src/providers/postgres-jira.ts +1188 -0
- package/src/providers/postgres-linear.ts +1088 -0
- package/src/providers/postgres-local.ts +611 -0
- package/src/server.ts +2 -2
- package/src/storage-config.ts +41 -0
- package/src/sync-config.ts +5 -2
- package/src/tracker-config.ts +104 -0
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
|
|
66
|
-
|
|
|
67
|
-
| `KANBAN_PROVIDER`
|
|
68
|
-
| `
|
|
69
|
-
| `
|
|
70
|
-
| `
|
|
71
|
-
| `
|
|
72
|
-
| `
|
|
73
|
-
| `
|
|
74
|
-
| `
|
|
75
|
-
| `
|
|
76
|
-
| `
|
|
77
|
-
| `
|
|
78
|
-
|
|
79
|
-
|
|
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
|
|
305
|
-
|
|
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.
|
|
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:
|
|
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([
|
|
63
|
-
|
|
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('
|
|
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(() =>
|
|
87
|
+
expect(() => trackerConfigFromEnv()).toThrow(KanbanError)
|
|
87
88
|
try {
|
|
88
|
-
|
|
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('
|
|
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
|
-
|
|
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
|
-
|
|
122
|
-
|
|
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
|
|
131
|
-
expect(provider
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
+
})
|