@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
@@ -0,0 +1,329 @@
1
+ import { ErrorCode } from '../errors.ts'
2
+ import { providerUpstreamError } from './errors.ts'
3
+
4
+ interface GraphQLResponse<T> {
5
+ data?: T
6
+ errors?: Array<{ message?: string; extensions?: { code?: string } }>
7
+ }
8
+
9
+ interface PageInfo {
10
+ hasNextPage: boolean
11
+ endCursor: string | null
12
+ }
13
+
14
+ export interface LinearTeamState {
15
+ id: string
16
+ name: string
17
+ position: number
18
+ color?: string | null
19
+ type?: string | null
20
+ }
21
+
22
+ export interface LinearIssue {
23
+ id: string
24
+ identifier: string
25
+ title: string
26
+ description?: string | null
27
+ priority?: number | null
28
+ url?: string | null
29
+ createdAt: string
30
+ updatedAt: string
31
+ assignee?: { id: string; name?: string | null; displayName?: string | null } | null
32
+ project?: { id: string; name: string; url?: string | null; state?: string | null } | null
33
+ state: { id: string; name: string; position: number }
34
+ }
35
+
36
+ interface LinearIssueNode {
37
+ id: string
38
+ identifier: string
39
+ title: string
40
+ description?: string | null
41
+ priority?: number | null
42
+ url?: string | null
43
+ createdAt: string
44
+ updatedAt: string
45
+ assignee?: { id: string; name?: string | null; displayName?: string | null } | null
46
+ project?: { id: string; name: string; url?: string | null; state?: string | null } | null
47
+ state: { id: string; name: string; position: number }
48
+ }
49
+
50
+ export class LinearClient {
51
+ private readonly endpoint = 'https://api.linear.app/graphql'
52
+
53
+ constructor(private readonly apiKey: string) {}
54
+
55
+ private async query<T>(query: string, variables: Record<string, unknown> = {}): Promise<T> {
56
+ const response = await fetch(this.endpoint, {
57
+ method: 'POST',
58
+ headers: {
59
+ 'Content-Type': 'application/json',
60
+ Authorization: this.apiKey,
61
+ },
62
+ body: JSON.stringify({ query, variables }),
63
+ })
64
+
65
+ if (response.status === 401 || response.status === 403) {
66
+ providerUpstreamError('Linear authentication failed', ErrorCode.PROVIDER_AUTH_FAILED)
67
+ }
68
+
69
+ if (response.status === 429) {
70
+ providerUpstreamError('Linear API rate limit exceeded', ErrorCode.PROVIDER_RATE_LIMITED)
71
+ }
72
+
73
+ if (!response.ok) {
74
+ providerUpstreamError(`Linear API request failed with ${response.status}`)
75
+ }
76
+
77
+ const body = (await response.json()) as GraphQLResponse<T>
78
+ if (body.errors?.length) {
79
+ const first = body.errors[0]
80
+ if (first?.extensions?.code === 'RATELIMITED') {
81
+ providerUpstreamError('Linear API rate limit exceeded', ErrorCode.PROVIDER_RATE_LIMITED)
82
+ }
83
+ providerUpstreamError(first?.message ?? 'Linear API request failed')
84
+ }
85
+
86
+ if (!body.data) {
87
+ providerUpstreamError('Linear API returned no data')
88
+ }
89
+
90
+ return body.data
91
+ }
92
+
93
+ async getTeam(
94
+ teamId: string,
95
+ ): Promise<{ id: string; key: string; name: string; states: LinearTeamState[] }> {
96
+ const data = await this.query<{
97
+ team: {
98
+ id: string
99
+ key: string
100
+ name: string
101
+ states: { nodes: LinearTeamState[] }
102
+ } | null
103
+ }>(
104
+ `
105
+ query TeamSnapshot($teamId: String!) {
106
+ team(id: $teamId) {
107
+ id
108
+ key
109
+ name
110
+ states {
111
+ nodes {
112
+ id
113
+ name
114
+ position
115
+ color
116
+ type
117
+ }
118
+ }
119
+ }
120
+ }
121
+ `,
122
+ { teamId },
123
+ )
124
+ if (!data.team) {
125
+ providerUpstreamError(`Linear team '${teamId}' was not found`)
126
+ }
127
+ return {
128
+ id: data.team.id,
129
+ key: data.team.key,
130
+ name: data.team.name,
131
+ states: data.team.states.nodes,
132
+ }
133
+ }
134
+
135
+ async listUsers(): Promise<Array<{ id: string; name: string; active?: boolean }>> {
136
+ const data = await this.query<{
137
+ users: {
138
+ nodes: Array<{
139
+ id: string
140
+ name?: string | null
141
+ displayName?: string | null
142
+ active?: boolean | null
143
+ }>
144
+ }
145
+ }>(
146
+ `
147
+ query Users {
148
+ users {
149
+ nodes {
150
+ id
151
+ name
152
+ displayName
153
+ active
154
+ }
155
+ }
156
+ }
157
+ `,
158
+ )
159
+ return data.users.nodes.map((user) => ({
160
+ id: user.id,
161
+ name: user.displayName || user.name || user.id,
162
+ active: user.active ?? true,
163
+ }))
164
+ }
165
+
166
+ async listProjects(): Promise<
167
+ Array<{ id: string; name: string; url?: string | null; state?: string | null }>
168
+ > {
169
+ const data = await this.query<{
170
+ projects: {
171
+ nodes: Array<{ id: string; name: string; url?: string | null; state?: string | null }>
172
+ }
173
+ }>(
174
+ `
175
+ query Projects {
176
+ projects {
177
+ nodes {
178
+ id
179
+ name
180
+ url
181
+ state
182
+ }
183
+ }
184
+ }
185
+ `,
186
+ )
187
+ return data.projects.nodes
188
+ }
189
+
190
+ async listIssues(teamId: string, updatedAfter?: string): Promise<LinearIssue[]> {
191
+ let after: string | null = null
192
+ const issues: LinearIssue[] = []
193
+
194
+ do {
195
+ const data: {
196
+ issues: {
197
+ nodes: LinearIssueNode[]
198
+ pageInfo: PageInfo
199
+ }
200
+ } = await this.query(
201
+ `
202
+ query Issues($teamId: ID!, $after: String, $updatedAfter: DateTimeOrDuration) {
203
+ issues(
204
+ first: 100
205
+ after: $after
206
+ orderBy: updatedAt
207
+ filter: {
208
+ team: { id: { eq: $teamId } }
209
+ updatedAt: { gte: $updatedAfter }
210
+ }
211
+ ) {
212
+ nodes {
213
+ id
214
+ identifier
215
+ title
216
+ description
217
+ priority
218
+ url
219
+ createdAt
220
+ updatedAt
221
+ assignee {
222
+ id
223
+ name
224
+ displayName
225
+ }
226
+ project {
227
+ id
228
+ name
229
+ url
230
+ state
231
+ }
232
+ state {
233
+ id
234
+ name
235
+ position
236
+ }
237
+ }
238
+ pageInfo {
239
+ hasNextPage
240
+ endCursor
241
+ }
242
+ }
243
+ }
244
+ `,
245
+ { teamId, after, updatedAfter: updatedAfter ?? '1970-01-01T00:00:00.000Z' },
246
+ )
247
+ issues.push(
248
+ ...data.issues.nodes.map((issue: LinearIssueNode) => ({
249
+ ...issue,
250
+ assignee: issue.assignee
251
+ ? {
252
+ id: issue.assignee.id,
253
+ name: issue.assignee.displayName || issue.assignee.name,
254
+ }
255
+ : null,
256
+ })),
257
+ )
258
+ after = data.issues.pageInfo.hasNextPage ? data.issues.pageInfo.endCursor : null
259
+ } while (after)
260
+
261
+ return issues
262
+ }
263
+
264
+ async createIssue(input: {
265
+ teamId: string
266
+ stateId?: string
267
+ title: string
268
+ description?: string
269
+ priority?: number
270
+ assigneeId?: string
271
+ projectId?: string
272
+ }): Promise<{ success: boolean; issue: LinearIssue | null }> {
273
+ const data = await this.query<{
274
+ issueCreate: { success: boolean; issue: LinearIssue | null }
275
+ }>(
276
+ `
277
+ mutation CreateIssue($input: IssueCreateInput!) {
278
+ issueCreate(input: $input) {
279
+ success
280
+ issue {
281
+ id
282
+ identifier
283
+ title
284
+ description
285
+ priority
286
+ url
287
+ createdAt
288
+ updatedAt
289
+ assignee { id name displayName }
290
+ project { id name url state }
291
+ state { id name position }
292
+ }
293
+ }
294
+ }
295
+ `,
296
+ {
297
+ input: {
298
+ teamId: input.teamId,
299
+ stateId: input.stateId,
300
+ title: input.title,
301
+ description: input.description,
302
+ priority: input.priority,
303
+ assigneeId: input.assigneeId,
304
+ projectId: input.projectId,
305
+ },
306
+ },
307
+ )
308
+ return data.issueCreate
309
+ }
310
+
311
+ async updateIssue(
312
+ issueId: string,
313
+ input: Record<string, unknown>,
314
+ ): Promise<{ success: boolean }> {
315
+ const data = await this.query<{
316
+ issueUpdate: { success: boolean }
317
+ }>(
318
+ `
319
+ mutation UpdateIssue($id: String!, $input: IssueUpdateInput!) {
320
+ issueUpdate(id: $id, input: $input) {
321
+ success
322
+ }
323
+ }
324
+ `,
325
+ { id: issueId, input },
326
+ )
327
+ return data.issueUpdate
328
+ }
329
+ }
@@ -0,0 +1,305 @@
1
+ import type { Database } from 'bun:sqlite'
2
+ import { ErrorCode, KanbanError } from '../errors.ts'
3
+ import type {
4
+ ActivityEntry,
5
+ BoardBootstrap,
6
+ BoardConfig,
7
+ BoardMetrics,
8
+ Column,
9
+ Task,
10
+ } from '../types.ts'
11
+ import { LINEAR_CAPABILITIES } from './capabilities.ts'
12
+ import {
13
+ getCachedBoard,
14
+ getCachedColumns,
15
+ getCachedConfig,
16
+ getCachedTask,
17
+ getCachedTasks,
18
+ initLinearCacheSchema,
19
+ loadSyncMeta,
20
+ replaceStates,
21
+ saveSyncMeta,
22
+ upsertIssues,
23
+ upsertProjects,
24
+ upsertUsers,
25
+ } from './linear-cache.ts'
26
+ import { LinearClient } from './linear-client.ts'
27
+ import { unsupportedOperation } from './errors.ts'
28
+ import type {
29
+ CreateTaskInput,
30
+ KanbanProvider,
31
+ ProviderContext,
32
+ TaskListFilters,
33
+ UpdateTaskInput,
34
+ } from './types.ts'
35
+
36
+ const SYNC_INTERVAL_MS = 30_000
37
+
38
+ function toLinearPriority(priority: Task['priority'] | undefined): number | undefined {
39
+ switch (priority) {
40
+ case 'urgent':
41
+ return 1
42
+ case 'high':
43
+ return 2
44
+ case 'medium':
45
+ return 3
46
+ case 'low':
47
+ return 4
48
+ default:
49
+ return undefined
50
+ }
51
+ }
52
+
53
+ export class LinearProvider implements KanbanProvider {
54
+ readonly type = 'linear' as const
55
+ private readonly client: LinearClient
56
+
57
+ constructor(
58
+ private readonly db: Database,
59
+ private readonly teamId: string,
60
+ apiKey: string,
61
+ ) {
62
+ initLinearCacheSchema(db)
63
+ this.client = new LinearClient(apiKey)
64
+ }
65
+
66
+ private async sync(force = false): Promise<void> {
67
+ const meta = loadSyncMeta(this.db)
68
+ const lastSyncAtMs = meta.lastSyncAt ? Date.parse(meta.lastSyncAt) : 0
69
+ if (!force && lastSyncAtMs && Date.now() - lastSyncAtMs < SYNC_INTERVAL_MS) return
70
+
71
+ const [team, users, projects, issues] = await Promise.all([
72
+ this.client.getTeam(this.teamId),
73
+ this.client.listUsers(),
74
+ this.client.listProjects(),
75
+ this.client.listIssues(
76
+ this.teamId,
77
+ force ? undefined : (meta.lastIssueUpdatedAt ?? undefined),
78
+ ),
79
+ ])
80
+
81
+ replaceStates(this.db, team.states)
82
+ upsertUsers(this.db, users)
83
+ upsertProjects(this.db, projects)
84
+ upsertIssues(
85
+ this.db,
86
+ issues.map((issue) => ({
87
+ id: issue.id,
88
+ identifier: issue.identifier,
89
+ title: issue.title,
90
+ description: issue.description ?? '',
91
+ priority: issue.priority ?? 0,
92
+ assigneeId: issue.assignee?.id ?? null,
93
+ assigneeName: issue.assignee?.name ?? null,
94
+ projectId: issue.project?.id ?? null,
95
+ projectName: issue.project?.name ?? null,
96
+ stateId: issue.state.id,
97
+ stateName: issue.state.name,
98
+ statePosition: issue.state.position,
99
+ url: issue.url ?? null,
100
+ createdAt: issue.createdAt,
101
+ updatedAt: issue.updatedAt,
102
+ })),
103
+ )
104
+
105
+ const newestIssueTimestamp =
106
+ issues.length > 0
107
+ ? issues.reduce(
108
+ (latest, issue) => (issue.updatedAt > latest ? issue.updatedAt : latest),
109
+ issues[0]!.updatedAt,
110
+ )
111
+ : meta.lastIssueUpdatedAt
112
+
113
+ saveSyncMeta(this.db, {
114
+ team: { id: team.id, key: team.key, name: team.name },
115
+ lastSyncAt: new Date().toISOString(),
116
+ lastIssueUpdatedAt: newestIssueTimestamp ?? new Date().toISOString(),
117
+ })
118
+ }
119
+
120
+ private resolveTask(idOrRef: string): Task {
121
+ const task = getCachedTask(this.db, idOrRef)
122
+ if (!task) {
123
+ throw new KanbanError(ErrorCode.TASK_NOT_FOUND, `No task with id '${idOrRef}'`)
124
+ }
125
+ return task
126
+ }
127
+
128
+ private resolveState(column: string): Column {
129
+ const states = getCachedColumns(this.db)
130
+ const match = states.find(
131
+ (state) => state.id === column || state.name.toLowerCase() === column.toLowerCase(),
132
+ )
133
+ if (!match) {
134
+ throw new KanbanError(
135
+ ErrorCode.COLUMN_NOT_FOUND,
136
+ `No Linear workflow state matching '${column}'`,
137
+ )
138
+ }
139
+ return match
140
+ }
141
+
142
+ private resolveAssigneeId(name?: string): string | undefined {
143
+ if (!name) return undefined
144
+ const row = this.db
145
+ .query('SELECT id FROM linear_users WHERE LOWER(name) = LOWER($name) LIMIT 1')
146
+ .get({ $name: name }) as { id: string } | null
147
+ return row?.id
148
+ }
149
+
150
+ private resolveProjectId(name?: string): string | undefined {
151
+ if (!name) return undefined
152
+ const row = this.db
153
+ .query('SELECT id FROM linear_projects WHERE LOWER(name) = LOWER($name) LIMIT 1')
154
+ .get({ $name: name }) as { id: string } | null
155
+ return row?.id
156
+ }
157
+
158
+ async getContext(): Promise<ProviderContext> {
159
+ await this.sync()
160
+ const meta = loadSyncMeta(this.db)
161
+ return {
162
+ provider: 'linear',
163
+ capabilities: LINEAR_CAPABILITIES,
164
+ team: meta.team,
165
+ }
166
+ }
167
+
168
+ async getBootstrap(): Promise<BoardBootstrap> {
169
+ await this.sync()
170
+ return {
171
+ provider: 'linear',
172
+ capabilities: LINEAR_CAPABILITIES,
173
+ board: getCachedBoard(this.db),
174
+ config: getCachedConfig(this.db),
175
+ metrics: null,
176
+ activity: [],
177
+ team: loadSyncMeta(this.db).team,
178
+ }
179
+ }
180
+
181
+ async getBoard() {
182
+ await this.sync()
183
+ return getCachedBoard(this.db)
184
+ }
185
+
186
+ async listColumns() {
187
+ await this.sync()
188
+ return getCachedColumns(this.db)
189
+ }
190
+
191
+ async listTasks(filters: TaskListFilters = {}) {
192
+ await this.sync()
193
+ let tasks = getCachedTasks(this.db)
194
+ if (filters.column) {
195
+ const column = this.resolveState(filters.column)
196
+ tasks = tasks.filter((task) => task.column_id === column.id)
197
+ }
198
+ if (filters.priority) tasks = tasks.filter((task) => task.priority === filters.priority)
199
+ if (filters.assignee) tasks = tasks.filter((task) => task.assignee === filters.assignee)
200
+ if (filters.project) tasks = tasks.filter((task) => task.project === filters.project)
201
+ if (filters.sort === 'title') tasks = [...tasks].sort((a, b) => a.title.localeCompare(b.title))
202
+ if (filters.sort === 'updated')
203
+ tasks = [...tasks].sort((a, b) => b.updated_at.localeCompare(a.updated_at))
204
+ if (filters.limit) tasks = tasks.slice(0, filters.limit)
205
+ return tasks
206
+ }
207
+
208
+ async getTask(idOrRef: string) {
209
+ await this.sync()
210
+ return this.resolveTask(idOrRef)
211
+ }
212
+
213
+ async createTask(input: CreateTaskInput) {
214
+ await this.sync()
215
+ const state = input.column ? this.resolveState(input.column) : undefined
216
+ const result = await this.client.createIssue({
217
+ teamId: this.teamId,
218
+ stateId: state?.id,
219
+ title: input.title,
220
+ description: input.description,
221
+ priority: toLinearPriority(input.priority),
222
+ assigneeId: this.resolveAssigneeId(input.assignee),
223
+ projectId: this.resolveProjectId(input.project),
224
+ })
225
+ if (!result.success || !result.issue) {
226
+ throw new KanbanError(ErrorCode.PROVIDER_UPSTREAM_ERROR, 'Linear issue creation failed')
227
+ }
228
+ const issue = result.issue
229
+ upsertIssues(this.db, [
230
+ {
231
+ id: issue.id,
232
+ identifier: issue.identifier,
233
+ title: issue.title,
234
+ description: issue.description ?? '',
235
+ priority: issue.priority ?? 0,
236
+ assigneeId: issue.assignee?.id ?? null,
237
+ assigneeName: issue.assignee?.name ?? issue.assignee?.displayName ?? '',
238
+ projectId: issue.project?.id ?? null,
239
+ projectName: issue.project?.name ?? '',
240
+ stateId: issue.state.id,
241
+ stateName: issue.state.name,
242
+ statePosition: issue.state.position,
243
+ url: issue.url ?? null,
244
+ createdAt: issue.createdAt,
245
+ updatedAt: issue.updatedAt,
246
+ },
247
+ ])
248
+ return this.resolveTask(issue.id)
249
+ }
250
+
251
+ async updateTask(idOrRef: string, input: UpdateTaskInput) {
252
+ await this.sync()
253
+ const task = this.resolveTask(idOrRef)
254
+ const updateInput: Record<string, unknown> = {}
255
+ if (input.title !== undefined) updateInput['title'] = input.title
256
+ if (input.description !== undefined) updateInput['description'] = input.description
257
+ if (input.priority !== undefined) updateInput['priority'] = toLinearPriority(input.priority)
258
+ if (input.assignee !== undefined)
259
+ updateInput['assigneeId'] = this.resolveAssigneeId(input.assignee) ?? null
260
+ if (input.project !== undefined)
261
+ updateInput['projectId'] = this.resolveProjectId(input.project) ?? null
262
+ if (input.metadata !== undefined) {
263
+ unsupportedOperation('Linear mode does not support metadata updates')
264
+ }
265
+ const result = await this.client.updateIssue(task.providerId || task.id, updateInput)
266
+ if (!result.success) {
267
+ throw new KanbanError(ErrorCode.PROVIDER_UPSTREAM_ERROR, 'Linear issue update failed')
268
+ }
269
+ await this.sync(true)
270
+ return this.resolveTask(task.providerId || task.id)
271
+ }
272
+
273
+ async moveTask(idOrRef: string, column: string) {
274
+ await this.sync()
275
+ const task = this.resolveTask(idOrRef)
276
+ const state = this.resolveState(column)
277
+ const result = await this.client.updateIssue(task.providerId || task.id, { stateId: state.id })
278
+ if (!result.success) {
279
+ throw new KanbanError(ErrorCode.PROVIDER_UPSTREAM_ERROR, 'Linear issue move failed')
280
+ }
281
+ await this.sync(true)
282
+ return this.resolveTask(task.providerId || task.id)
283
+ }
284
+
285
+ async deleteTask(_idOrRef: string): Promise<Task> {
286
+ unsupportedOperation('Task deletion is not supported in Linear mode')
287
+ }
288
+
289
+ async getActivity(_limit?: number, _taskId?: string): Promise<ActivityEntry[]> {
290
+ unsupportedOperation('Activity is not available in Linear mode')
291
+ }
292
+
293
+ async getMetrics(): Promise<BoardMetrics> {
294
+ unsupportedOperation('Metrics are not available in Linear mode')
295
+ }
296
+
297
+ async getConfig(): Promise<BoardConfig> {
298
+ await this.sync()
299
+ return getCachedConfig(this.db)
300
+ }
301
+
302
+ async patchConfig(_input: Partial<BoardConfig>): Promise<BoardConfig> {
303
+ unsupportedOperation('Config mutation is not supported in Linear mode')
304
+ }
305
+ }