@andypai/agent-kanban 0.1.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.
Files changed (41) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +306 -0
  3. package/package.json +80 -0
  4. package/src/__tests__/activity.test.ts +139 -0
  5. package/src/__tests__/api.test.ts +74 -0
  6. package/src/__tests__/commands/board.test.ts +51 -0
  7. package/src/__tests__/commands/bulk.test.ts +51 -0
  8. package/src/__tests__/commands/column.test.ts +78 -0
  9. package/src/__tests__/commands/task.test.ts +144 -0
  10. package/src/__tests__/db.test.ts +327 -0
  11. package/src/__tests__/id.test.ts +19 -0
  12. package/src/__tests__/index.test.ts +75 -0
  13. package/src/__tests__/metrics.test.ts +64 -0
  14. package/src/__tests__/output.test.ts +39 -0
  15. package/src/activity.ts +73 -0
  16. package/src/api.ts +209 -0
  17. package/src/commands/board.ts +29 -0
  18. package/src/commands/bulk.ts +19 -0
  19. package/src/commands/column.ts +60 -0
  20. package/src/commands/task.ts +117 -0
  21. package/src/config.ts +29 -0
  22. package/src/db.ts +587 -0
  23. package/src/errors.ts +32 -0
  24. package/src/fixtures.ts +128 -0
  25. package/src/id.ts +8 -0
  26. package/src/index.ts +413 -0
  27. package/src/metrics.ts +98 -0
  28. package/src/output.ts +105 -0
  29. package/src/providers/capabilities.ts +25 -0
  30. package/src/providers/errors.ts +16 -0
  31. package/src/providers/index.ts +24 -0
  32. package/src/providers/linear-cache.ts +385 -0
  33. package/src/providers/linear-client.ts +329 -0
  34. package/src/providers/linear.ts +305 -0
  35. package/src/providers/local.ts +135 -0
  36. package/src/providers/types.ts +65 -0
  37. package/src/server.ts +91 -0
  38. package/src/types.ts +123 -0
  39. package/ui/dist/assets/index-DEnUD0fq.css +1 -0
  40. package/ui/dist/assets/index-DMRjw1nI.js +40 -0
  41. package/ui/dist/index.html +13 -0
package/src/output.ts ADDED
@@ -0,0 +1,105 @@
1
+ import type { CliOutput, BoardView, Task, TaskWithColumn, Column } from './types.ts'
2
+
3
+ export function success<T>(data: T): CliOutput<T> {
4
+ return { ok: true, data }
5
+ }
6
+
7
+ export function error(code: string, message: string): CliOutput<never> {
8
+ return { ok: false, error: { code, message } }
9
+ }
10
+
11
+ export function formatOutput(result: CliOutput, pretty: boolean): string {
12
+ if (!pretty) return JSON.stringify(result)
13
+ if (!result.ok) return formatError(result.error)
14
+ return formatPrettyData(result.data)
15
+ }
16
+
17
+ function formatError(err: { code: string; message: string }): string {
18
+ return `Error [${err.code}]: ${err.message}`
19
+ }
20
+
21
+ function formatPrettyData(data: unknown): string {
22
+ if (data && typeof data === 'object' && 'columns' in data) {
23
+ return formatBoard(data as BoardView)
24
+ }
25
+ if (Array.isArray(data)) {
26
+ if (data.length === 0) return 'No items found.'
27
+ if ('column_id' in data[0]) return data.map(formatTaskLine).join('\n')
28
+ if ('position' in data[0]) return data.map(formatColumnLine).join('\n')
29
+ return JSON.stringify(data, null, 2)
30
+ }
31
+ if (data && typeof data === 'object' && 'column_id' in data) {
32
+ return formatTaskDetail(data as TaskWithColumn)
33
+ }
34
+ if (data && typeof data === 'object' && 'moved' in data) {
35
+ return `Moved ${(data as { moved: number }).moved} task(s).`
36
+ }
37
+ if (data && typeof data === 'object' && 'deleted' in data) {
38
+ return `Deleted ${(data as { deleted: number }).deleted} task(s).`
39
+ }
40
+ if (data && typeof data === 'object' && 'position' in data && 'name' in data) {
41
+ return formatColumnLine(data as Column)
42
+ }
43
+ if (data && typeof data === 'object' && 'message' in data) {
44
+ return (data as { message: string }).message
45
+ }
46
+ return JSON.stringify(data, null, 2)
47
+ }
48
+
49
+ const PRIORITY_ICONS: Record<string, string> = {
50
+ urgent: '!!!',
51
+ high: '!! ',
52
+ medium: '! ',
53
+ low: '. ',
54
+ }
55
+
56
+ function formatTaskLine(task: Task): string {
57
+ const pri = PRIORITY_ICONS[task.priority] ?? ' '
58
+ const assignee = task.assignee ? ` @${task.assignee}` : ''
59
+ const project = task.project ? ` [${task.project}]` : ''
60
+ const ref = task.externalRef && task.externalRef !== task.id ? ` (${task.externalRef})` : ''
61
+ return ` [${pri}] ${task.id}${ref} ${task.title}${assignee}${project}`
62
+ }
63
+
64
+ function formatTaskDetail(task: Task): string {
65
+ const lines = [
66
+ `Task: ${task.id}`,
67
+ ...(task.externalRef && task.externalRef !== task.id ? [`Ref: ${task.externalRef}`] : []),
68
+ `Title: ${task.title}`,
69
+ `Priority: ${task.priority}`,
70
+ ]
71
+ if ('column_name' in task && task.column_name) lines.push(`Column: ${task.column_name}`)
72
+ if (task.assignee) lines.push(`Assignee: ${task.assignee}`)
73
+ if (task.project) lines.push(`Project: ${task.project}`)
74
+ if (task.description) lines.push(`Description: ${task.description}`)
75
+ if (task.metadata !== '{}') lines.push(`Metadata: ${task.metadata}`)
76
+ if (task.url) lines.push(`URL: ${task.url}`)
77
+ lines.push(`Created: ${task.created_at}`)
78
+ lines.push(`Updated: ${task.updated_at}`)
79
+ return lines.join('\n')
80
+ }
81
+
82
+ function formatColumnLine(col: Column): string {
83
+ const color = col.color ? ` (${col.color})` : ''
84
+ return ` ${col.position}. ${col.name}${color} [${col.id}]`
85
+ }
86
+
87
+ function formatBoard(board: BoardView): string {
88
+ const lines: string[] = []
89
+ for (const col of board.columns) {
90
+ const count = col.tasks.length
91
+ lines.push(`── ${col.name} (${count}) ──`)
92
+ if (count === 0) {
93
+ lines.push(' (empty)')
94
+ } else {
95
+ for (const task of col.tasks) {
96
+ const pri = PRIORITY_ICONS[task.priority] ?? ' '
97
+ const assignee = task.assignee ? ` @${task.assignee}` : ''
98
+ const project = task.project ? ` [${task.project}]` : ''
99
+ lines.push(` [${pri}] ${task.id} ${task.title}${assignee}${project}`)
100
+ }
101
+ }
102
+ lines.push('')
103
+ }
104
+ return lines.join('\n').trimEnd()
105
+ }
@@ -0,0 +1,25 @@
1
+ import type { ProviderCapabilities } from '../types.ts'
2
+
3
+ export const LOCAL_CAPABILITIES: ProviderCapabilities = {
4
+ taskCreate: true,
5
+ taskUpdate: true,
6
+ taskMove: true,
7
+ taskDelete: true,
8
+ activity: true,
9
+ metrics: true,
10
+ columnCrud: true,
11
+ bulk: true,
12
+ configEdit: true,
13
+ }
14
+
15
+ export const LINEAR_CAPABILITIES: ProviderCapabilities = {
16
+ taskCreate: true,
17
+ taskUpdate: true,
18
+ taskMove: true,
19
+ taskDelete: false,
20
+ activity: false,
21
+ metrics: false,
22
+ columnCrud: false,
23
+ bulk: false,
24
+ configEdit: false,
25
+ }
@@ -0,0 +1,16 @@
1
+ import { ErrorCode, type ErrorCodeValue, KanbanError } from '../errors.ts'
2
+
3
+ export function unsupportedOperation(message: string): never {
4
+ throw new KanbanError(ErrorCode.UNSUPPORTED_OPERATION, message)
5
+ }
6
+
7
+ export function providerNotConfigured(message: string): never {
8
+ throw new KanbanError(ErrorCode.PROVIDER_NOT_CONFIGURED, message)
9
+ }
10
+
11
+ export function providerUpstreamError(
12
+ message: string,
13
+ code: ErrorCodeValue = ErrorCode.PROVIDER_UPSTREAM_ERROR,
14
+ ): never {
15
+ throw new KanbanError(code, message)
16
+ }
@@ -0,0 +1,24 @@
1
+ import type { Database } from 'bun:sqlite'
2
+ import { getDbPath, initSchema, seedDefaultColumns } from '../db.ts'
3
+ import { providerNotConfigured } from './errors.ts'
4
+ import { LinearProvider } from './linear.ts'
5
+ import { LocalProvider } from './local.ts'
6
+ import type { KanbanProvider } from './types.ts'
7
+
8
+ export function createProvider(db: Database, dbPath = getDbPath()): KanbanProvider {
9
+ const providerType = (process.env['KANBAN_PROVIDER'] ?? 'local') as 'local' | 'linear'
10
+ if (providerType === 'linear') {
11
+ const apiKey = process.env['LINEAR_API_KEY']
12
+ const teamId = process.env['LINEAR_TEAM_ID']
13
+ if (!apiKey || !teamId) {
14
+ providerNotConfigured(
15
+ 'LINEAR_API_KEY and LINEAR_TEAM_ID are required when KANBAN_PROVIDER=linear',
16
+ )
17
+ }
18
+ return new LinearProvider(db, teamId!, apiKey!)
19
+ }
20
+
21
+ initSchema(db)
22
+ seedDefaultColumns(db)
23
+ return new LocalProvider(db, dbPath)
24
+ }
@@ -0,0 +1,385 @@
1
+ import type { Database } from 'bun:sqlite'
2
+ import type { BoardConfig, BoardView, ProviderTeamInfo, Task } from '../types.ts'
3
+
4
+ export interface LinearStateRow {
5
+ id: string
6
+ name: string
7
+ position: number
8
+ color: string | null
9
+ type: string | null
10
+ created_at: string
11
+ updated_at: string
12
+ }
13
+
14
+ export interface LinearSyncMeta {
15
+ team: ProviderTeamInfo | null
16
+ lastSyncAt: string | null
17
+ lastIssueUpdatedAt: string | null
18
+ }
19
+
20
+ export function initLinearCacheSchema(db: Database): void {
21
+ db.run(`
22
+ CREATE TABLE IF NOT EXISTS linear_sync_meta (
23
+ key TEXT PRIMARY KEY,
24
+ value TEXT NOT NULL
25
+ )
26
+ `)
27
+ db.run(`
28
+ CREATE TABLE IF NOT EXISTS linear_states (
29
+ id TEXT PRIMARY KEY,
30
+ name TEXT NOT NULL,
31
+ position INTEGER NOT NULL,
32
+ color TEXT,
33
+ type TEXT,
34
+ created_at TEXT NOT NULL,
35
+ updated_at TEXT NOT NULL
36
+ )
37
+ `)
38
+ db.run(`
39
+ CREATE TABLE IF NOT EXISTS linear_users (
40
+ id TEXT PRIMARY KEY,
41
+ name TEXT NOT NULL,
42
+ active INTEGER NOT NULL DEFAULT 1,
43
+ updated_at TEXT NOT NULL
44
+ )
45
+ `)
46
+ db.run(`
47
+ CREATE TABLE IF NOT EXISTS linear_projects (
48
+ id TEXT PRIMARY KEY,
49
+ name TEXT NOT NULL,
50
+ url TEXT,
51
+ state TEXT,
52
+ updated_at TEXT NOT NULL
53
+ )
54
+ `)
55
+ db.run(`
56
+ CREATE TABLE IF NOT EXISTS linear_issues (
57
+ id TEXT PRIMARY KEY,
58
+ identifier TEXT NOT NULL UNIQUE,
59
+ title TEXT NOT NULL,
60
+ description TEXT NOT NULL DEFAULT '',
61
+ priority INTEGER NOT NULL DEFAULT 0,
62
+ assignee_id TEXT,
63
+ assignee_name TEXT NOT NULL DEFAULT '',
64
+ project_id TEXT,
65
+ project_name TEXT NOT NULL DEFAULT '',
66
+ state_id TEXT NOT NULL,
67
+ state_name TEXT NOT NULL,
68
+ state_position INTEGER NOT NULL DEFAULT 0,
69
+ url TEXT,
70
+ created_at TEXT NOT NULL,
71
+ updated_at TEXT NOT NULL
72
+ )
73
+ `)
74
+ db.run('CREATE INDEX IF NOT EXISTS idx_linear_issues_state_id ON linear_issues(state_id)')
75
+ db.run('CREATE INDEX IF NOT EXISTS idx_linear_issues_updated_at ON linear_issues(updated_at)')
76
+ }
77
+
78
+ function setMeta(db: Database, key: string, value: string): void {
79
+ db.query(
80
+ `INSERT INTO linear_sync_meta (key, value) VALUES ($key, $value)
81
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
82
+ ).run({ $key: key, $value: value })
83
+ }
84
+
85
+ function getMeta(db: Database, key: string): string | null {
86
+ const row = db.query('SELECT value FROM linear_sync_meta WHERE key = $key').get({
87
+ $key: key,
88
+ }) as { value: string } | null
89
+ return row?.value ?? null
90
+ }
91
+
92
+ export function saveSyncMeta(db: Database, meta: LinearSyncMeta): void {
93
+ if (meta.team) setMeta(db, 'team', JSON.stringify(meta.team))
94
+ if (meta.lastSyncAt) setMeta(db, 'lastSyncAt', meta.lastSyncAt)
95
+ if (meta.lastIssueUpdatedAt) setMeta(db, 'lastIssueUpdatedAt', meta.lastIssueUpdatedAt)
96
+ }
97
+
98
+ export function loadSyncMeta(db: Database): LinearSyncMeta {
99
+ const teamRaw = getMeta(db, 'team')
100
+ return {
101
+ team: teamRaw ? (JSON.parse(teamRaw) as ProviderTeamInfo) : null,
102
+ lastSyncAt: getMeta(db, 'lastSyncAt'),
103
+ lastIssueUpdatedAt: getMeta(db, 'lastIssueUpdatedAt'),
104
+ }
105
+ }
106
+
107
+ export function replaceStates(
108
+ db: Database,
109
+ states: Array<{
110
+ id: string
111
+ name: string
112
+ position: number
113
+ color?: string | null
114
+ type?: string | null
115
+ }>,
116
+ ): void {
117
+ const run = db.transaction(() => {
118
+ db.run('DELETE FROM linear_states')
119
+ const stmt = db.prepare(
120
+ `INSERT INTO linear_states (id, name, position, color, type, created_at, updated_at)
121
+ VALUES ($id, $name, $position, $color, $type, datetime('now'), datetime('now'))`,
122
+ )
123
+ for (const state of states) {
124
+ stmt.run({
125
+ $id: state.id,
126
+ $name: state.name,
127
+ $position: state.position,
128
+ $color: state.color ?? null,
129
+ $type: state.type ?? null,
130
+ })
131
+ }
132
+ })
133
+ run()
134
+ }
135
+
136
+ export function upsertUsers(
137
+ db: Database,
138
+ users: Array<{ id: string; name: string; active?: boolean }>,
139
+ ): void {
140
+ const stmt = db.prepare(
141
+ `INSERT INTO linear_users (id, name, active, updated_at)
142
+ VALUES ($id, $name, $active, datetime('now'))
143
+ ON CONFLICT(id) DO UPDATE SET
144
+ name = excluded.name,
145
+ active = excluded.active,
146
+ updated_at = excluded.updated_at`,
147
+ )
148
+ for (const user of users) {
149
+ stmt.run({ $id: user.id, $name: user.name, $active: user.active === false ? 0 : 1 })
150
+ }
151
+ }
152
+
153
+ export function upsertProjects(
154
+ db: Database,
155
+ projects: Array<{ id: string; name: string; url?: string | null; state?: string | null }>,
156
+ ): void {
157
+ const stmt = db.prepare(
158
+ `INSERT INTO linear_projects (id, name, url, state, updated_at)
159
+ VALUES ($id, $name, $url, $state, datetime('now'))
160
+ ON CONFLICT(id) DO UPDATE SET
161
+ name = excluded.name,
162
+ url = excluded.url,
163
+ state = excluded.state,
164
+ updated_at = excluded.updated_at`,
165
+ )
166
+ for (const project of projects) {
167
+ stmt.run({
168
+ $id: project.id,
169
+ $name: project.name,
170
+ $url: project.url ?? null,
171
+ $state: project.state ?? null,
172
+ })
173
+ }
174
+ }
175
+
176
+ export function upsertIssues(
177
+ db: Database,
178
+ issues: Array<{
179
+ id: string
180
+ identifier: string
181
+ title: string
182
+ description?: string | null
183
+ priority?: number | null
184
+ assigneeId?: string | null
185
+ assigneeName?: string | null
186
+ projectId?: string | null
187
+ projectName?: string | null
188
+ stateId: string
189
+ stateName: string
190
+ statePosition: number
191
+ url?: string | null
192
+ createdAt: string
193
+ updatedAt: string
194
+ }>,
195
+ ): void {
196
+ const stmt = db.prepare(
197
+ `INSERT INTO linear_issues (
198
+ id, identifier, title, description, priority, assignee_id, assignee_name,
199
+ project_id, project_name, state_id, state_name, state_position, url, created_at, updated_at
200
+ ) VALUES (
201
+ $id, $identifier, $title, $description, $priority, $assignee_id, $assignee_name,
202
+ $project_id, $project_name, $state_id, $state_name, $state_position, $url, $created_at, $updated_at
203
+ )
204
+ ON CONFLICT(id) DO UPDATE SET
205
+ identifier = excluded.identifier,
206
+ title = excluded.title,
207
+ description = excluded.description,
208
+ priority = excluded.priority,
209
+ assignee_id = excluded.assignee_id,
210
+ assignee_name = excluded.assignee_name,
211
+ project_id = excluded.project_id,
212
+ project_name = excluded.project_name,
213
+ state_id = excluded.state_id,
214
+ state_name = excluded.state_name,
215
+ state_position = excluded.state_position,
216
+ url = excluded.url,
217
+ created_at = excluded.created_at,
218
+ updated_at = excluded.updated_at`,
219
+ )
220
+ for (const issue of issues) {
221
+ stmt.run({
222
+ $id: issue.id,
223
+ $identifier: issue.identifier,
224
+ $title: issue.title,
225
+ $description: issue.description ?? '',
226
+ $priority: issue.priority ?? 0,
227
+ $assignee_id: issue.assigneeId ?? null,
228
+ $assignee_name: issue.assigneeName ?? '',
229
+ $project_id: issue.projectId ?? null,
230
+ $project_name: issue.projectName ?? '',
231
+ $state_id: issue.stateId,
232
+ $state_name: issue.stateName,
233
+ $state_position: issue.statePosition,
234
+ $url: issue.url ?? null,
235
+ $created_at: issue.createdAt,
236
+ $updated_at: issue.updatedAt,
237
+ })
238
+ }
239
+ }
240
+
241
+ export function getCachedColumns(db: Database): LinearStateRow[] {
242
+ return db.query('SELECT * FROM linear_states ORDER BY position, name').all() as LinearStateRow[]
243
+ }
244
+
245
+ function mapPriority(priority: number): Task['priority'] {
246
+ switch (priority) {
247
+ case 1:
248
+ return 'urgent'
249
+ case 2:
250
+ return 'high'
251
+ case 3:
252
+ return 'medium'
253
+ case 0:
254
+ case 4:
255
+ default:
256
+ return 'low'
257
+ }
258
+ }
259
+
260
+ function taskFromRow(row: {
261
+ id: string
262
+ identifier: string
263
+ title: string
264
+ description: string
265
+ state_id: string
266
+ state_position: number
267
+ priority: number
268
+ assignee_name: string
269
+ project_name: string
270
+ url: string | null
271
+ created_at: string
272
+ updated_at: string
273
+ }): Task {
274
+ return {
275
+ id: `linear:${row.id}`,
276
+ providerId: row.id,
277
+ externalRef: row.identifier,
278
+ url: row.url,
279
+ title: row.title,
280
+ description: row.description,
281
+ column_id: row.state_id,
282
+ position: row.state_position,
283
+ priority: mapPriority(row.priority),
284
+ assignee: row.assignee_name,
285
+ project: row.project_name,
286
+ metadata: '{}',
287
+ created_at: row.created_at,
288
+ updated_at: row.updated_at,
289
+ }
290
+ }
291
+
292
+ export function getCachedBoard(db: Database): BoardView {
293
+ const columns = getCachedColumns(db)
294
+ return {
295
+ columns: columns.map((column) => ({
296
+ ...column,
297
+ tasks: (
298
+ db
299
+ .query(
300
+ `SELECT * FROM linear_issues
301
+ WHERE state_id = $state_id
302
+ ORDER BY updated_at DESC, title ASC`,
303
+ )
304
+ .all({ $state_id: column.id }) as Array<{
305
+ id: string
306
+ identifier: string
307
+ title: string
308
+ description: string
309
+ state_id: string
310
+ state_position: number
311
+ priority: number
312
+ assignee_name: string
313
+ project_name: string
314
+ url: string | null
315
+ created_at: string
316
+ updated_at: string
317
+ }>
318
+ ).map(taskFromRow),
319
+ })),
320
+ }
321
+ }
322
+
323
+ export function getCachedTask(db: Database, lookup: string): Task | null {
324
+ const normalized = lookup.startsWith('linear:') ? lookup.slice('linear:'.length) : lookup
325
+ const row = db
326
+ .query(
327
+ `SELECT * FROM linear_issues
328
+ WHERE id = $lookup OR identifier = $lookup
329
+ LIMIT 1`,
330
+ )
331
+ .get({ $lookup: normalized }) as {
332
+ id: string
333
+ identifier: string
334
+ title: string
335
+ description: string
336
+ state_id: string
337
+ state_position: number
338
+ priority: number
339
+ assignee_name: string
340
+ project_name: string
341
+ url: string | null
342
+ created_at: string
343
+ updated_at: string
344
+ } | null
345
+ return row ? taskFromRow(row) : null
346
+ }
347
+
348
+ export function getCachedTasks(db: Database): Task[] {
349
+ return (
350
+ db.query('SELECT * FROM linear_issues ORDER BY updated_at DESC, title ASC').all() as Array<{
351
+ id: string
352
+ identifier: string
353
+ title: string
354
+ description: string
355
+ state_id: string
356
+ state_position: number
357
+ priority: number
358
+ assignee_name: string
359
+ project_name: string
360
+ url: string | null
361
+ created_at: string
362
+ updated_at: string
363
+ }>
364
+ ).map(taskFromRow)
365
+ }
366
+
367
+ export function getCachedConfig(db: Database): BoardConfig {
368
+ const members = (
369
+ db
370
+ .query("SELECT name FROM linear_users WHERE active = 1 AND name != '' ORDER BY name")
371
+ .all() as { name: string }[]
372
+ ).map((row) => ({ name: row.name, role: 'human' as const }))
373
+ const projects = (
374
+ db.query("SELECT name FROM linear_projects WHERE name != '' ORDER BY name").all() as {
375
+ name: string
376
+ }[]
377
+ ).map((row) => row.name)
378
+ return {
379
+ members,
380
+ projects,
381
+ provider: 'linear',
382
+ discoveredAssignees: members.map((member) => member.name),
383
+ discoveredProjects: projects,
384
+ }
385
+ }