@andypai/agent-kanban 0.2.0 → 0.3.0
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 +89 -22
- package/package.json +4 -2
- package/src/__tests__/activity.test.ts +15 -9
- package/src/__tests__/api.test.ts +96 -0
- package/src/__tests__/board-utils.test.ts +100 -0
- package/src/__tests__/commands/board.test.ts +6 -13
- package/src/__tests__/conflict.test.ts +64 -0
- package/src/__tests__/index.test.ts +233 -56
- package/src/__tests__/jira-adf.test.ts +168 -0
- package/src/__tests__/jira-cache.test.ts +304 -0
- package/src/__tests__/jira-client.test.ts +169 -0
- package/src/__tests__/jira-provider-comment.test.ts +281 -0
- package/src/__tests__/jira-provider-mutations.test.ts +771 -0
- package/src/__tests__/jira-provider-read.test.ts +594 -0
- package/src/__tests__/jira-wiring.test.ts +187 -0
- package/src/__tests__/linear-cache-description-activity.test.ts +142 -0
- package/src/__tests__/linear-provider-comment.test.ts +243 -0
- package/src/__tests__/linear-provider-sync.test.ts +493 -0
- package/src/__tests__/local-provider-comment.test.ts +60 -0
- package/src/__tests__/mcp-core.test.ts +164 -0
- package/src/__tests__/mcp-server.test.ts +252 -0
- package/src/__tests__/server.test.ts +298 -0
- package/src/__tests__/webhooks.test.ts +604 -0
- package/src/activity.ts +1 -11
- package/src/api.ts +154 -19
- package/src/commands/board.ts +1 -11
- package/src/commands/mcp.ts +87 -0
- package/src/db.ts +115 -3
- package/src/errors.ts +2 -0
- package/src/id.ts +1 -1
- package/src/index.ts +72 -18
- package/src/mcp/core.ts +193 -0
- package/src/mcp/errors.ts +109 -0
- package/src/mcp/index.ts +13 -0
- package/src/mcp/server.ts +512 -0
- package/src/mcp/types.ts +72 -0
- package/src/providers/capabilities.ts +15 -0
- package/src/providers/index.ts +31 -1
- package/src/providers/jira-adf.ts +275 -0
- package/src/providers/jira-cache.ts +625 -0
- package/src/providers/jira-client.ts +390 -0
- package/src/providers/jira.ts +778 -0
- package/src/providers/linear-cache.ts +249 -70
- package/src/providers/linear-client.ts +256 -13
- package/src/providers/linear.ts +337 -14
- package/src/providers/local.ts +68 -17
- package/src/providers/types.ts +18 -2
- package/src/server.ts +139 -11
- package/src/tunnel.ts +79 -0
- package/src/types.ts +18 -2
- package/src/webhooks.ts +36 -0
- package/ui/dist/assets/index-DBnoKL_k.css +1 -0
- package/ui/dist/assets/index-qNVJ6clH.js +40 -0
- package/ui/dist/index.html +2 -2
- package/src/__tests__/commands/task.test.ts +0 -144
- package/src/commands/task.ts +0 -117
- package/src/fixtures.ts +0 -128
- package/ui/dist/assets/index-B8f9NB4z.css +0 -1
- package/ui/dist/assets/index-zWp-rB7b.js +0 -40
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { afterAll, afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
|
2
|
+
import { Database } from 'bun:sqlite'
|
|
3
|
+
import { mkdtempSync, rmSync } from 'node:fs'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { ErrorCode, KanbanError } from '../errors.ts'
|
|
7
|
+
import { run } from '../index.ts'
|
|
8
|
+
import { createProvider } from '../providers/index.ts'
|
|
9
|
+
|
|
10
|
+
const ENV_KEYS = [
|
|
11
|
+
'KANBAN_PROVIDER',
|
|
12
|
+
'JIRA_BASE_URL',
|
|
13
|
+
'JIRA_EMAIL',
|
|
14
|
+
'JIRA_API_TOKEN',
|
|
15
|
+
'JIRA_PROJECT_KEY',
|
|
16
|
+
'JIRA_BOARD_ID',
|
|
17
|
+
'JIRA_ISSUE_TYPE',
|
|
18
|
+
'LINEAR_API_KEY',
|
|
19
|
+
'LINEAR_TEAM_ID',
|
|
20
|
+
] as const
|
|
21
|
+
|
|
22
|
+
const tempRoot = mkdtempSync(join(tmpdir(), 'jira-wiring-'))
|
|
23
|
+
const dbs: Database[] = []
|
|
24
|
+
|
|
25
|
+
function makeDb(): { db: Database; dbPath: string } {
|
|
26
|
+
const dir = mkdtempSync(join(tempRoot, 'case-'))
|
|
27
|
+
const dbPath = join(dir, 'board.db')
|
|
28
|
+
const db = new Database(dbPath)
|
|
29
|
+
dbs.push(db)
|
|
30
|
+
return { db, dbPath }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function setJiraRequiredEnv(): void {
|
|
34
|
+
process.env['KANBAN_PROVIDER'] = 'jira'
|
|
35
|
+
process.env['JIRA_BASE_URL'] = 'https://example.atlassian.net'
|
|
36
|
+
process.env['JIRA_EMAIL'] = 'a@example.com'
|
|
37
|
+
process.env['JIRA_API_TOKEN'] = 'tok-test'
|
|
38
|
+
process.env['JIRA_PROJECT_KEY'] = 'ENG'
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe('jira-wiring', () => {
|
|
42
|
+
let snapshot: Record<string, string | undefined>
|
|
43
|
+
|
|
44
|
+
beforeEach(() => {
|
|
45
|
+
snapshot = {}
|
|
46
|
+
for (const key of ENV_KEYS) {
|
|
47
|
+
snapshot[key] = process.env[key]
|
|
48
|
+
delete process.env[key]
|
|
49
|
+
}
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
afterEach(() => {
|
|
53
|
+
for (const key of ENV_KEYS) {
|
|
54
|
+
const prev = snapshot[key]
|
|
55
|
+
if (prev === undefined) {
|
|
56
|
+
delete process.env[key]
|
|
57
|
+
} else {
|
|
58
|
+
process.env[key] = prev
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
afterAll(() => {
|
|
64
|
+
for (const db of dbs) {
|
|
65
|
+
try {
|
|
66
|
+
db.close()
|
|
67
|
+
} catch {
|
|
68
|
+
// ignore
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
rmSync(tempRoot, { recursive: true, force: true })
|
|
73
|
+
} catch {
|
|
74
|
+
// ignore
|
|
75
|
+
}
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('createProvider throws PROVIDER_NOT_CONFIGURED listing missing JIRA_API_TOKEN', () => {
|
|
79
|
+
const { db, dbPath } = makeDb()
|
|
80
|
+
process.env['KANBAN_PROVIDER'] = 'jira'
|
|
81
|
+
process.env['JIRA_BASE_URL'] = 'https://example.atlassian.net'
|
|
82
|
+
process.env['JIRA_EMAIL'] = 'a@example.com'
|
|
83
|
+
delete process.env['JIRA_API_TOKEN']
|
|
84
|
+
process.env['JIRA_PROJECT_KEY'] = 'ENG'
|
|
85
|
+
|
|
86
|
+
expect(() => createProvider(db, dbPath)).toThrow(KanbanError)
|
|
87
|
+
try {
|
|
88
|
+
createProvider(db, dbPath)
|
|
89
|
+
} catch (err) {
|
|
90
|
+
expect((err as KanbanError).code).toBe(ErrorCode.PROVIDER_NOT_CONFIGURED)
|
|
91
|
+
expect((err as Error).message).toContain('JIRA_API_TOKEN')
|
|
92
|
+
expect((err as Error).message).toContain('KANBAN_PROVIDER=jira')
|
|
93
|
+
}
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test('createProvider throws PROVIDER_NOT_CONFIGURED listing multiple missing vars', () => {
|
|
97
|
+
const { db, dbPath } = makeDb()
|
|
98
|
+
process.env['KANBAN_PROVIDER'] = 'jira'
|
|
99
|
+
process.env['JIRA_BASE_URL'] = 'https://example.atlassian.net'
|
|
100
|
+
delete process.env['JIRA_EMAIL']
|
|
101
|
+
delete process.env['JIRA_API_TOKEN']
|
|
102
|
+
process.env['JIRA_PROJECT_KEY'] = 'ENG'
|
|
103
|
+
|
|
104
|
+
let msg = ''
|
|
105
|
+
let code: string | undefined
|
|
106
|
+
try {
|
|
107
|
+
createProvider(db, dbPath)
|
|
108
|
+
} catch (err) {
|
|
109
|
+
msg = (err as Error).message
|
|
110
|
+
code = (err as KanbanError).code
|
|
111
|
+
}
|
|
112
|
+
expect(code).toBe(ErrorCode.PROVIDER_NOT_CONFIGURED)
|
|
113
|
+
expect(msg).toContain('JIRA_EMAIL')
|
|
114
|
+
expect(msg).toContain('JIRA_API_TOKEN')
|
|
115
|
+
expect(msg).toContain('are required')
|
|
116
|
+
expect(msg).toContain('KANBAN_PROVIDER=jira')
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
test('createProvider builds JiraProvider when all four required vars are set', () => {
|
|
120
|
+
const { db, dbPath } = makeDb()
|
|
121
|
+
setJiraRequiredEnv()
|
|
122
|
+
const provider = createProvider(db, dbPath)
|
|
123
|
+
expect(provider.type).toBe('jira')
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
test('JIRA_BOARD_ID non-numeric falls back to undefined', () => {
|
|
127
|
+
const { db, dbPath } = makeDb()
|
|
128
|
+
setJiraRequiredEnv()
|
|
129
|
+
process.env['JIRA_BOARD_ID'] = 'notanumber'
|
|
130
|
+
const provider = createProvider(db, dbPath)
|
|
131
|
+
expect(provider.type).toBe('jira')
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
test('kanban column add under KANBAN_PROVIDER=jira exits with UNSUPPORTED_OPERATION', async () => {
|
|
135
|
+
const { dbPath } = makeDb()
|
|
136
|
+
setJiraRequiredEnv()
|
|
137
|
+
const result = await run(['--db', dbPath, 'column', 'add', 'NewColumn']).catch(
|
|
138
|
+
(err: unknown) => ({ error: err as KanbanError }),
|
|
139
|
+
)
|
|
140
|
+
expect('error' in result).toBe(true)
|
|
141
|
+
const err = (result as { error: KanbanError }).error
|
|
142
|
+
expect(err).toBeInstanceOf(KanbanError)
|
|
143
|
+
expect(err.code).toBe(ErrorCode.UNSUPPORTED_OPERATION)
|
|
144
|
+
expect(err.message).toContain('Column commands')
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
test('kanban bulk move-all under KANBAN_PROVIDER=jira exits with UNSUPPORTED_OPERATION', async () => {
|
|
148
|
+
const { dbPath } = makeDb()
|
|
149
|
+
setJiraRequiredEnv()
|
|
150
|
+
const result = await run(['--db', dbPath, 'bulk', 'move-all', 'a', 'b']).catch(
|
|
151
|
+
(err: unknown) => ({ error: err as KanbanError }),
|
|
152
|
+
)
|
|
153
|
+
expect('error' in result).toBe(true)
|
|
154
|
+
const err = (result as { error: KanbanError }).error
|
|
155
|
+
expect(err).toBeInstanceOf(KanbanError)
|
|
156
|
+
expect(err.code).toBe(ErrorCode.UNSUPPORTED_OPERATION)
|
|
157
|
+
expect(err.message).toContain('Bulk commands')
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
test('kanban config set-member under KANBAN_PROVIDER=jira exits with UNSUPPORTED_OPERATION', async () => {
|
|
161
|
+
const { dbPath } = makeDb()
|
|
162
|
+
setJiraRequiredEnv()
|
|
163
|
+
const result = await run([
|
|
164
|
+
'--db',
|
|
165
|
+
dbPath,
|
|
166
|
+
'config',
|
|
167
|
+
'set-member',
|
|
168
|
+
'alice',
|
|
169
|
+
'--role',
|
|
170
|
+
'human',
|
|
171
|
+
]).catch((err: unknown) => ({ error: err as KanbanError }))
|
|
172
|
+
expect('error' in result).toBe(true)
|
|
173
|
+
const err = (result as { error: KanbanError }).error
|
|
174
|
+
expect(err).toBeInstanceOf(KanbanError)
|
|
175
|
+
expect(err.code).toBe(ErrorCode.UNSUPPORTED_OPERATION)
|
|
176
|
+
expect(err.message).toContain('Config mutation')
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
test('KANBAN_PROVIDER=linear still builds LinearProvider (regression guard)', () => {
|
|
180
|
+
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)
|
|
185
|
+
expect(provider.type).toBe('linear')
|
|
186
|
+
})
|
|
187
|
+
})
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { Database } from 'bun:sqlite'
|
|
2
|
+
import { describe, expect, test } from 'bun:test'
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
deleteLinearIssue,
|
|
6
|
+
getCachedLinearActivity,
|
|
7
|
+
initLinearCacheSchema,
|
|
8
|
+
upsertIssues,
|
|
9
|
+
} from '../providers/linear-cache.ts'
|
|
10
|
+
|
|
11
|
+
function mkIssue(overrides: Partial<Parameters<typeof upsertIssues>[1][0]> = {}) {
|
|
12
|
+
return {
|
|
13
|
+
id: 'issue-1',
|
|
14
|
+
identifier: 'ABC-1',
|
|
15
|
+
title: 'Task',
|
|
16
|
+
description: '',
|
|
17
|
+
priority: 0,
|
|
18
|
+
assigneeId: null,
|
|
19
|
+
assigneeName: null,
|
|
20
|
+
projectId: null,
|
|
21
|
+
projectName: null,
|
|
22
|
+
stateId: 'state-1',
|
|
23
|
+
stateName: 'Todo',
|
|
24
|
+
statePosition: 0,
|
|
25
|
+
labels: [],
|
|
26
|
+
commentCount: 0,
|
|
27
|
+
url: null,
|
|
28
|
+
createdAt: '2026-04-19T00:00:00.000Z',
|
|
29
|
+
updatedAt: '2026-04-19T00:00:00.000Z',
|
|
30
|
+
...overrides,
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function freshDb(): Database {
|
|
35
|
+
const db = new Database(':memory:')
|
|
36
|
+
initLinearCacheSchema(db)
|
|
37
|
+
return db
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function descriptionRows(db: Database, issueId = 'issue-1') {
|
|
41
|
+
return getCachedLinearActivity(db, { issueId }).filter((r) => r.item_field === 'description')
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe('upsertIssues description activity', () => {
|
|
45
|
+
test('emits an activity row when description changes between upserts', () => {
|
|
46
|
+
const db = freshDb()
|
|
47
|
+
upsertIssues(db, [mkIssue({ description: 'first' })])
|
|
48
|
+
upsertIssues(db, [mkIssue({ description: 'second', updatedAt: '2026-04-19T00:01:00.000Z' })])
|
|
49
|
+
const rows = descriptionRows(db)
|
|
50
|
+
expect(rows).toHaveLength(1)
|
|
51
|
+
expect(rows[0]!.from_value).toBe('first')
|
|
52
|
+
expect(rows[0]!.to_value).toBe('second')
|
|
53
|
+
expect(rows[0]!.created_at).toBe('2026-04-19T00:01:00.000Z')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('does not emit on first insert (no prior value to diff)', () => {
|
|
57
|
+
const db = freshDb()
|
|
58
|
+
upsertIssues(db, [mkIssue({ description: 'hello' })])
|
|
59
|
+
expect(descriptionRows(db)).toHaveLength(0)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('skips unchanged descriptions', () => {
|
|
63
|
+
const db = freshDb()
|
|
64
|
+
upsertIssues(db, [mkIssue({ description: 'same' })])
|
|
65
|
+
upsertIssues(db, [mkIssue({ description: 'same', updatedAt: '2026-04-19T00:01:00.000Z' })])
|
|
66
|
+
expect(descriptionRows(db)).toHaveLength(0)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test('is idempotent when the same updatedAt is replayed', () => {
|
|
70
|
+
const db = freshDb()
|
|
71
|
+
upsertIssues(db, [mkIssue({ description: 'a' })])
|
|
72
|
+
const edited = mkIssue({ description: 'b', updatedAt: '2026-04-19T00:01:00.000Z' })
|
|
73
|
+
upsertIssues(db, [edited])
|
|
74
|
+
upsertIssues(db, [edited])
|
|
75
|
+
expect(descriptionRows(db)).toHaveLength(1)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('treats null/undefined descriptions as empty string', () => {
|
|
79
|
+
const db = freshDb()
|
|
80
|
+
upsertIssues(db, [mkIssue({ description: 'had content' })])
|
|
81
|
+
upsertIssues(db, [mkIssue({ description: null, updatedAt: '2026-04-19T00:01:00.000Z' })])
|
|
82
|
+
const rows = descriptionRows(db)
|
|
83
|
+
expect(rows).toHaveLength(1)
|
|
84
|
+
expect(rows[0]!.from_value).toBe('had content')
|
|
85
|
+
expect(rows[0]!.to_value).toBe('')
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test('emits when an empty description is populated', () => {
|
|
89
|
+
const db = freshDb()
|
|
90
|
+
upsertIssues(db, [mkIssue({ description: '' })])
|
|
91
|
+
upsertIssues(db, [
|
|
92
|
+
mkIssue({ description: 'now has content', updatedAt: '2026-04-19T00:01:00.000Z' }),
|
|
93
|
+
])
|
|
94
|
+
const rows = descriptionRows(db)
|
|
95
|
+
expect(rows).toHaveLength(1)
|
|
96
|
+
expect(rows[0]!.from_value).toBe('')
|
|
97
|
+
expect(rows[0]!.to_value).toBe('now has content')
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
test('truncates very long description values to cap storage growth', () => {
|
|
101
|
+
const db = freshDb()
|
|
102
|
+
upsertIssues(db, [mkIssue({ description: 'x'.repeat(10_000) })])
|
|
103
|
+
upsertIssues(db, [
|
|
104
|
+
mkIssue({ description: 'y'.repeat(10_000), updatedAt: '2026-04-19T00:01:00.000Z' }),
|
|
105
|
+
])
|
|
106
|
+
const row = descriptionRows(db)[0]!
|
|
107
|
+
expect(row.from_value!.length).toBeLessThanOrEqual(4096)
|
|
108
|
+
expect(row.to_value!.length).toBeLessThanOrEqual(4096)
|
|
109
|
+
expect(row.from_value!.endsWith('…[truncated]')).toBe(true)
|
|
110
|
+
expect(row.to_value!.endsWith('…[truncated]')).toBe(true)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
test('preserves existing comment_count when an upsert omits it', () => {
|
|
114
|
+
const db = freshDb()
|
|
115
|
+
upsertIssues(db, [mkIssue({ commentCount: 4 })])
|
|
116
|
+
upsertIssues(db, [
|
|
117
|
+
mkIssue({
|
|
118
|
+
title: 'Task renamed',
|
|
119
|
+
commentCount: undefined,
|
|
120
|
+
updatedAt: '2026-04-19T00:01:00.000Z',
|
|
121
|
+
}),
|
|
122
|
+
])
|
|
123
|
+
|
|
124
|
+
const row = db
|
|
125
|
+
.query('SELECT comment_count, title FROM linear_issues WHERE id = $id')
|
|
126
|
+
.get({ $id: 'issue-1' }) as { comment_count: number; title: string } | null
|
|
127
|
+
|
|
128
|
+
expect(row?.title).toBe('Task renamed')
|
|
129
|
+
expect(row?.comment_count).toBe(4)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
test('deleteLinearIssue clears cached activity rows for the deleted issue', () => {
|
|
133
|
+
const db = freshDb()
|
|
134
|
+
upsertIssues(db, [mkIssue({ description: 'first' })])
|
|
135
|
+
upsertIssues(db, [mkIssue({ description: 'second', updatedAt: '2026-04-19T00:01:00.000Z' })])
|
|
136
|
+
|
|
137
|
+
expect(getCachedLinearActivity(db, { issueId: 'issue-1' })).toHaveLength(1)
|
|
138
|
+
deleteLinearIssue(db, 'issue-1')
|
|
139
|
+
|
|
140
|
+
expect(getCachedLinearActivity(db, { issueId: 'issue-1' })).toHaveLength(0)
|
|
141
|
+
})
|
|
142
|
+
})
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
|
2
|
+
import { Database } from 'bun:sqlite'
|
|
3
|
+
import { LinearProvider } from '../providers/linear.ts'
|
|
4
|
+
import {
|
|
5
|
+
initLinearCacheSchema,
|
|
6
|
+
replaceStates,
|
|
7
|
+
saveSyncMeta,
|
|
8
|
+
upsertIssues,
|
|
9
|
+
} from '../providers/linear-cache.ts'
|
|
10
|
+
|
|
11
|
+
let db: Database
|
|
12
|
+
let originalFetch: typeof fetch
|
|
13
|
+
let requests: Array<{ query: string; variables: Record<string, unknown> }>
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
db = new Database(':memory:')
|
|
17
|
+
initLinearCacheSchema(db)
|
|
18
|
+
replaceStates(db, [{ id: 'state-1', name: 'Todo', position: 0 }])
|
|
19
|
+
upsertIssues(db, [
|
|
20
|
+
{
|
|
21
|
+
id: 'issue-1',
|
|
22
|
+
identifier: 'ENG-1',
|
|
23
|
+
title: 'Issue 1',
|
|
24
|
+
stateId: 'state-1',
|
|
25
|
+
stateName: 'Todo',
|
|
26
|
+
statePosition: 0,
|
|
27
|
+
commentCount: 0,
|
|
28
|
+
createdAt: '2026-01-01T00:00:00Z',
|
|
29
|
+
updatedAt: '2026-01-02T00:00:00Z',
|
|
30
|
+
},
|
|
31
|
+
])
|
|
32
|
+
saveSyncMeta(db, {
|
|
33
|
+
team: { id: 'team-1', key: 'ENG', name: 'Engineering' },
|
|
34
|
+
lastSyncAt: new Date().toISOString(),
|
|
35
|
+
lastIssueUpdatedAt: '2026-01-02T00:00:00Z',
|
|
36
|
+
})
|
|
37
|
+
originalFetch = globalThis.fetch
|
|
38
|
+
requests = []
|
|
39
|
+
globalThis.fetch = (async (_input: string | URL | Request, init?: RequestInit) => {
|
|
40
|
+
const body = JSON.parse(String(init?.body)) as {
|
|
41
|
+
query: string
|
|
42
|
+
variables: Record<string, unknown>
|
|
43
|
+
}
|
|
44
|
+
requests.push(body)
|
|
45
|
+
|
|
46
|
+
if (body.query.includes('mutation CommentCreate')) {
|
|
47
|
+
return new Response(
|
|
48
|
+
JSON.stringify({
|
|
49
|
+
data: {
|
|
50
|
+
commentCreate: {
|
|
51
|
+
success: true,
|
|
52
|
+
comment: {
|
|
53
|
+
id: 'comment-1',
|
|
54
|
+
body: String((body.variables.input as { body: string }).body),
|
|
55
|
+
createdAt: '2026-01-03T00:00:00Z',
|
|
56
|
+
updatedAt: '2026-01-03T00:00:00Z',
|
|
57
|
+
user: { id: 'user-1', displayName: 'Linear User' },
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
}),
|
|
62
|
+
{
|
|
63
|
+
status: 200,
|
|
64
|
+
headers: { 'content-type': 'application/json' },
|
|
65
|
+
},
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (body.query.includes('mutation CommentUpdate')) {
|
|
70
|
+
return new Response(
|
|
71
|
+
JSON.stringify({
|
|
72
|
+
data: {
|
|
73
|
+
commentUpdate: {
|
|
74
|
+
success: true,
|
|
75
|
+
comment: {
|
|
76
|
+
id: String(body.variables.id),
|
|
77
|
+
body: String((body.variables.input as { body: string }).body),
|
|
78
|
+
createdAt: '2026-01-03T00:00:00Z',
|
|
79
|
+
updatedAt: '2026-01-04T00:00:00Z',
|
|
80
|
+
user: { id: 'user-1', displayName: 'Linear User' },
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
}),
|
|
85
|
+
{
|
|
86
|
+
status: 200,
|
|
87
|
+
headers: { 'content-type': 'application/json' },
|
|
88
|
+
},
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (body.query.includes('query Comment(')) {
|
|
93
|
+
return new Response(
|
|
94
|
+
JSON.stringify({
|
|
95
|
+
data: {
|
|
96
|
+
comment: {
|
|
97
|
+
id: String(body.variables.id),
|
|
98
|
+
body: 'one linear comment',
|
|
99
|
+
createdAt: '2026-01-03T00:00:00Z',
|
|
100
|
+
updatedAt: '2026-01-05T00:00:00Z',
|
|
101
|
+
user: { id: 'user-1', displayName: 'Linear User' },
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
}),
|
|
105
|
+
{
|
|
106
|
+
status: 200,
|
|
107
|
+
headers: { 'content-type': 'application/json' },
|
|
108
|
+
},
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (body.query.includes('query IssueComments')) {
|
|
113
|
+
return new Response(
|
|
114
|
+
JSON.stringify({
|
|
115
|
+
data: {
|
|
116
|
+
issue: {
|
|
117
|
+
comments: {
|
|
118
|
+
nodes: [
|
|
119
|
+
{
|
|
120
|
+
id: 'comment-1',
|
|
121
|
+
body: 'first linear comment',
|
|
122
|
+
createdAt: '2026-01-03T00:00:00Z',
|
|
123
|
+
updatedAt: '2026-01-03T00:00:00Z',
|
|
124
|
+
user: { id: 'user-1', displayName: 'Linear User' },
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
id: 'comment-2',
|
|
128
|
+
body: 'second linear comment',
|
|
129
|
+
createdAt: '2026-01-04T00:00:00Z',
|
|
130
|
+
updatedAt: '2026-01-04T00:00:00Z',
|
|
131
|
+
user: { id: 'user-2', displayName: 'Reviewer' },
|
|
132
|
+
},
|
|
133
|
+
],
|
|
134
|
+
pageInfo: { hasNextPage: false, endCursor: null },
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
}),
|
|
139
|
+
{
|
|
140
|
+
status: 200,
|
|
141
|
+
headers: { 'content-type': 'application/json' },
|
|
142
|
+
},
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
throw new Error(`Unexpected Linear GraphQL query: ${body.query}`)
|
|
147
|
+
}) as unknown as typeof fetch
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
afterEach(() => {
|
|
151
|
+
globalThis.fetch = originalFetch
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
describe('LinearProvider.comment', () => {
|
|
155
|
+
test('posts the commentCreate mutation and advertises comment capability', async () => {
|
|
156
|
+
const provider = new LinearProvider(db, 'team-1', 'lin_api_test')
|
|
157
|
+
|
|
158
|
+
const comment = await provider.comment('ENG-1', 'hello from linear')
|
|
159
|
+
|
|
160
|
+
expect(requests[0]?.query).toContain('mutation CommentCreate')
|
|
161
|
+
expect(requests[0]?.variables).toEqual({
|
|
162
|
+
input: {
|
|
163
|
+
issueId: 'issue-1',
|
|
164
|
+
body: 'hello from linear',
|
|
165
|
+
},
|
|
166
|
+
})
|
|
167
|
+
expect(comment).toMatchObject({
|
|
168
|
+
id: 'comment-1',
|
|
169
|
+
task_id: 'linear:issue-1',
|
|
170
|
+
body: 'hello from linear',
|
|
171
|
+
author: 'Linear User',
|
|
172
|
+
})
|
|
173
|
+
expect((await provider.getTask('ENG-1')).comment_count).toBe(1)
|
|
174
|
+
|
|
175
|
+
const context = await provider.getContext()
|
|
176
|
+
expect(context.capabilities.comment).toBe(true)
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
test('queries issue comments and normalizes them into TaskComment rows', async () => {
|
|
180
|
+
const provider = new LinearProvider(db, 'team-1', 'lin_api_test')
|
|
181
|
+
|
|
182
|
+
const comments = await provider.listComments('ENG-1')
|
|
183
|
+
|
|
184
|
+
expect(requests[0]?.query).toContain('query IssueComments')
|
|
185
|
+
expect(requests[0]?.variables).toEqual({
|
|
186
|
+
issueId: 'issue-1',
|
|
187
|
+
after: null,
|
|
188
|
+
})
|
|
189
|
+
expect(comments).toEqual([
|
|
190
|
+
{
|
|
191
|
+
id: 'comment-1',
|
|
192
|
+
task_id: 'linear:issue-1',
|
|
193
|
+
body: 'first linear comment',
|
|
194
|
+
author: 'Linear User',
|
|
195
|
+
created_at: '2026-01-03T00:00:00Z',
|
|
196
|
+
updated_at: '2026-01-03T00:00:00Z',
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
id: 'comment-2',
|
|
200
|
+
task_id: 'linear:issue-1',
|
|
201
|
+
body: 'second linear comment',
|
|
202
|
+
author: 'Reviewer',
|
|
203
|
+
created_at: '2026-01-04T00:00:00Z',
|
|
204
|
+
updated_at: '2026-01-04T00:00:00Z',
|
|
205
|
+
},
|
|
206
|
+
])
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
test('queries a single Linear comment by id', async () => {
|
|
210
|
+
const provider = new LinearProvider(db, 'team-1', 'lin_api_test')
|
|
211
|
+
|
|
212
|
+
const comment = await provider.getComment('ENG-1', 'comment-1')
|
|
213
|
+
|
|
214
|
+
expect(requests[0]?.query).toContain('query Comment(')
|
|
215
|
+
expect(requests[0]?.variables).toEqual({ id: 'comment-1' })
|
|
216
|
+
expect(comment).toMatchObject({
|
|
217
|
+
id: 'comment-1',
|
|
218
|
+
task_id: 'linear:issue-1',
|
|
219
|
+
body: 'one linear comment',
|
|
220
|
+
author: 'Linear User',
|
|
221
|
+
updated_at: '2026-01-05T00:00:00Z',
|
|
222
|
+
})
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
test('posts the commentUpdate mutation with the provided id', async () => {
|
|
226
|
+
const provider = new LinearProvider(db, 'team-1', 'lin_api_test')
|
|
227
|
+
|
|
228
|
+
const comment = await provider.updateComment('ENG-1', 'comment-1', 'edited linear comment')
|
|
229
|
+
|
|
230
|
+
expect(requests[0]?.query).toContain('mutation CommentUpdate')
|
|
231
|
+
expect(requests[0]?.variables).toEqual({
|
|
232
|
+
id: 'comment-1',
|
|
233
|
+
input: {
|
|
234
|
+
body: 'edited linear comment',
|
|
235
|
+
},
|
|
236
|
+
})
|
|
237
|
+
expect(comment).toMatchObject({
|
|
238
|
+
id: 'comment-1',
|
|
239
|
+
task_id: 'linear:issue-1',
|
|
240
|
+
body: 'edited linear comment',
|
|
241
|
+
})
|
|
242
|
+
})
|
|
243
|
+
})
|