@andypai/agent-kanban 0.6.3 → 0.6.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.
Files changed (45) hide show
  1. package/package.json +11 -3
  2. package/src/__tests__/index.test.ts +27 -1
  3. package/src/__tests__/jira-cache.test.ts +68 -0
  4. package/src/__tests__/jira-provider-read.test.ts +130 -0
  5. package/src/__tests__/jira-wiring.test.ts +41 -0
  6. package/src/__tests__/linear-provider-comment.test.ts +81 -0
  7. package/src/__tests__/linear-provider-sync.test.ts +107 -0
  8. package/src/__tests__/linear-state-resolution.test.ts +60 -0
  9. package/src/__tests__/local-provider-core.test.ts +245 -0
  10. package/src/__tests__/postgres-jira-provider.test.ts +55 -0
  11. package/src/__tests__/postgres-linear-provider.test.ts +90 -0
  12. package/src/__tests__/postgres-local-provider.test.ts +26 -0
  13. package/src/__tests__/provider-sync-core.test.ts +150 -0
  14. package/src/__tests__/provider-team-info.test.ts +37 -0
  15. package/src/__tests__/ui-board-slice.test.ts +14 -0
  16. package/src/__tests__/use-cases.test.ts +21 -42
  17. package/src/api.ts +78 -132
  18. package/src/column-roles.ts +3 -2
  19. package/src/index.ts +135 -92
  20. package/src/mcp/core.ts +7 -8
  21. package/src/provider-runtime.ts +12 -56
  22. package/src/providers/cache-task-mappers.ts +123 -0
  23. package/src/providers/factory.ts +103 -0
  24. package/src/providers/index.ts +4 -25
  25. package/src/providers/jira-cache.ts +41 -99
  26. package/src/providers/jira-core.ts +81 -81
  27. package/src/providers/linear-cache.ts +42 -69
  28. package/src/providers/linear-core.ts +44 -56
  29. package/src/providers/local-core.ts +226 -0
  30. package/src/providers/local.ts +6 -182
  31. package/src/providers/postgres-batch.ts +10 -0
  32. package/src/providers/postgres-jira-cache.ts +255 -213
  33. package/src/providers/postgres-jira.ts +2 -20
  34. package/src/providers/postgres-linear-cache.ts +279 -183
  35. package/src/providers/postgres-linear.ts +2 -20
  36. package/src/providers/postgres-local.ts +22 -47
  37. package/src/providers/sqlite-local-store.ts +121 -0
  38. package/src/providers/sync-core.ts +91 -0
  39. package/src/providers/team-info.ts +24 -0
  40. package/src/providers/warn-once.ts +15 -0
  41. package/src/use-cases.ts +7 -131
  42. package/src/webhook-events.ts +29 -1
  43. package/ui/dist/assets/index-DcZH7fI3.js +40 -0
  44. package/ui/dist/index.html +1 -1
  45. package/ui/dist/assets/index-Cigv8a9S.js +0 -40
@@ -0,0 +1,226 @@
1
+ import { ErrorCode, KanbanError } from '../errors'
2
+ import type {
3
+ ActivityEntry,
4
+ BoardBootstrap,
5
+ BoardConfig,
6
+ BoardMetrics,
7
+ BoardView,
8
+ Column,
9
+ ProviderCapabilities,
10
+ Task,
11
+ TaskComment,
12
+ } from '../types'
13
+ import type {
14
+ CreateTaskInput,
15
+ KanbanProvider,
16
+ ProviderContext,
17
+ ProviderSyncStatus,
18
+ TaskListFilters,
19
+ UpdateTaskInput,
20
+ } from './types'
21
+
22
+ type Awaitable<T> = T | Promise<T>
23
+
24
+ export type LocalTaskRecord = Task & {
25
+ column_name?: string
26
+ }
27
+
28
+ export interface LocalStorePort {
29
+ readonly capabilities: ProviderCapabilities
30
+ initialize?(): Promise<void>
31
+ getBoard(): Awaitable<BoardView>
32
+ listColumns(): Awaitable<Column[]>
33
+ listTasks(filters?: TaskListFilters): Awaitable<LocalTaskRecord[]>
34
+ getTask(idOrRef: string): Awaitable<LocalTaskRecord>
35
+ getTaskVersion(idOrRef: string): Awaitable<string>
36
+ createTask(input: CreateTaskInput): Awaitable<LocalTaskRecord>
37
+ updateTask(
38
+ idOrRef: string,
39
+ input: Omit<UpdateTaskInput, 'expectedVersion'>,
40
+ ): Awaitable<LocalTaskRecord>
41
+ moveTask(idOrRef: string, column: string): Awaitable<LocalTaskRecord>
42
+ deleteTask(idOrRef: string): Awaitable<LocalTaskRecord>
43
+ listComments(idOrRef: string): Awaitable<TaskComment[]>
44
+ getComment(idOrRef: string, commentId: string): Awaitable<TaskComment>
45
+ comment(idOrRef: string, body: string): Awaitable<TaskComment>
46
+ updateComment(idOrRef: string, commentId: string, body: string): Awaitable<TaskComment>
47
+ getActivity(limit?: number, taskId?: string): Awaitable<ActivityEntry[]>
48
+ getMetrics(): Awaitable<BoardMetrics>
49
+ getConfig(context?: { metrics?: BoardMetrics }): Awaitable<BoardConfig>
50
+ patchConfig(input: Partial<BoardConfig>): Awaitable<BoardConfig>
51
+ countComments?(taskId: string): Awaitable<number>
52
+ countCommentsByTask?(): Awaitable<Map<string, number>>
53
+ }
54
+
55
+ function taskHasCommentCount(task: Task): boolean {
56
+ return typeof task.comment_count === 'number' && Number.isFinite(task.comment_count)
57
+ }
58
+
59
+ export class LocalProviderCore implements KanbanProvider {
60
+ readonly type = 'local' as const
61
+
62
+ constructor(private readonly store: LocalStorePort) {}
63
+
64
+ async initialize(): Promise<void> {
65
+ await this.store.initialize?.()
66
+ }
67
+
68
+ private enrichTask(task: LocalTaskRecord, commentCount?: number): LocalTaskRecord {
69
+ const revision = task.revision ?? 0
70
+ const assignees = task.assignees?.length ? task.assignees : task.assignee ? [task.assignee] : []
71
+ const labels = Array.isArray(task.labels) ? task.labels : []
72
+ return {
73
+ ...task,
74
+ providerId: task.providerId ?? task.id,
75
+ externalRef: task.externalRef ?? task.id,
76
+ url: task.url ?? null,
77
+ assignees,
78
+ labels,
79
+ comment_count: commentCount ?? task.comment_count ?? 0,
80
+ version: task.version ?? String(revision),
81
+ source_updated_at: task.source_updated_at ?? null,
82
+ }
83
+ }
84
+
85
+ private async commentCount(task: Task): Promise<number | undefined> {
86
+ if (taskHasCommentCount(task)) return task.comment_count
87
+ return this.store.countComments?.(task.id)
88
+ }
89
+
90
+ private async commentCountsFor(tasks: Task[]): Promise<Map<string, number> | undefined> {
91
+ if (tasks.every(taskHasCommentCount)) return undefined
92
+ return this.store.countCommentsByTask?.()
93
+ }
94
+
95
+ private async enrichTaskWithCount(task: LocalTaskRecord): Promise<LocalTaskRecord> {
96
+ return this.enrichTask(task, await this.commentCount(task))
97
+ }
98
+
99
+ async getContext(): Promise<ProviderContext> {
100
+ await this.initialize()
101
+ return {
102
+ provider: this.type,
103
+ capabilities: this.store.capabilities,
104
+ team: null,
105
+ }
106
+ }
107
+
108
+ async getBootstrap(): Promise<BoardBootstrap> {
109
+ await this.initialize()
110
+ const metrics = await this.getMetrics()
111
+ return {
112
+ provider: this.type,
113
+ capabilities: this.store.capabilities,
114
+ board: await this.getBoard(),
115
+ config: await this.store.getConfig({ metrics }),
116
+ metrics,
117
+ activity: await this.getActivity(50),
118
+ team: null,
119
+ }
120
+ }
121
+
122
+ async getBoard(): Promise<BoardView> {
123
+ await this.initialize()
124
+ const board = await this.store.getBoard()
125
+ const tasks = board.columns.flatMap((column) => column.tasks)
126
+ const counts = await this.commentCountsFor(tasks)
127
+ return {
128
+ columns: board.columns.map((column) => ({
129
+ ...column,
130
+ tasks: column.tasks.map((task) => this.enrichTask(task, counts?.get(task.id))),
131
+ })),
132
+ }
133
+ }
134
+
135
+ async listColumns(): Promise<Column[]> {
136
+ await this.initialize()
137
+ return this.store.listColumns()
138
+ }
139
+
140
+ async listTasks(filters: TaskListFilters = {}): Promise<Task[]> {
141
+ await this.initialize()
142
+ const tasks = await this.store.listTasks(filters)
143
+ const counts = await this.commentCountsFor(tasks)
144
+ return tasks.map((task) => this.enrichTask(task, counts?.get(task.id)))
145
+ }
146
+
147
+ async getTask(idOrRef: string): Promise<Task> {
148
+ await this.initialize()
149
+ return this.enrichTaskWithCount(await this.store.getTask(idOrRef))
150
+ }
151
+
152
+ async createTask(input: CreateTaskInput): Promise<Task> {
153
+ await this.initialize()
154
+ return this.enrichTaskWithCount(await this.store.createTask(input))
155
+ }
156
+
157
+ async updateTask(idOrRef: string, input: UpdateTaskInput): Promise<Task> {
158
+ await this.initialize()
159
+ if (input.expectedVersion !== undefined) {
160
+ const currentVersion = await this.store.getTaskVersion(idOrRef)
161
+ if (currentVersion !== input.expectedVersion) {
162
+ throw new KanbanError(
163
+ ErrorCode.CONFLICT,
164
+ `Task ${idOrRef} was modified since you loaded it (expected version ${input.expectedVersion}, current ${currentVersion})`,
165
+ )
166
+ }
167
+ }
168
+ const updates: Omit<UpdateTaskInput, 'expectedVersion'> = { ...input }
169
+ delete (updates as UpdateTaskInput).expectedVersion
170
+ return this.enrichTaskWithCount(await this.store.updateTask(idOrRef, updates))
171
+ }
172
+
173
+ async moveTask(idOrRef: string, column: string): Promise<Task> {
174
+ await this.initialize()
175
+ return this.enrichTaskWithCount(await this.store.moveTask(idOrRef, column))
176
+ }
177
+
178
+ async deleteTask(idOrRef: string): Promise<Task> {
179
+ await this.initialize()
180
+ return this.enrichTaskWithCount(await this.store.deleteTask(idOrRef))
181
+ }
182
+
183
+ async listComments(idOrRef: string): Promise<TaskComment[]> {
184
+ await this.initialize()
185
+ return this.store.listComments(idOrRef)
186
+ }
187
+
188
+ async getComment(idOrRef: string, commentId: string): Promise<TaskComment> {
189
+ await this.initialize()
190
+ return this.store.getComment(idOrRef, commentId)
191
+ }
192
+
193
+ async comment(idOrRef: string, body: string): Promise<TaskComment> {
194
+ await this.initialize()
195
+ return this.store.comment(idOrRef, body)
196
+ }
197
+
198
+ async updateComment(idOrRef: string, commentId: string, body: string): Promise<TaskComment> {
199
+ await this.initialize()
200
+ return this.store.updateComment(idOrRef, commentId, body)
201
+ }
202
+
203
+ async getActivity(limit?: number, taskId?: string): Promise<ActivityEntry[]> {
204
+ await this.initialize()
205
+ return this.store.getActivity(limit, taskId)
206
+ }
207
+
208
+ async getMetrics(): Promise<BoardMetrics> {
209
+ await this.initialize()
210
+ return this.store.getMetrics()
211
+ }
212
+
213
+ async getConfig(): Promise<BoardConfig> {
214
+ await this.initialize()
215
+ return this.store.getConfig()
216
+ }
217
+
218
+ async patchConfig(input: Partial<BoardConfig>): Promise<BoardConfig> {
219
+ await this.initialize()
220
+ return this.store.patchConfig(input)
221
+ }
222
+
223
+ async getSyncStatus(): Promise<ProviderSyncStatus | null> {
224
+ return null
225
+ }
226
+ }
@@ -1,187 +1,11 @@
1
1
  import type { Database } from 'bun:sqlite'
2
- import { listActivity } from '../activity'
3
- import { getConfigPath, loadConfig, saveConfig } from '../config'
4
- import {
5
- addComment,
6
- countComments,
7
- countCommentsByTask,
8
- addTask,
9
- deleteTask,
10
- getBoardView,
11
- getComment as getTaskComment,
12
- getDbPath,
13
- getTask,
14
- listComments as listTaskComments,
15
- listColumns,
16
- listTasks,
17
- moveTask,
18
- updateComment as updateTaskComment,
19
- updateTask,
20
- } from '../db'
21
- import { getBoardMetrics, getDiscoveredAssignees, getDiscoveredProjects } from '../metrics'
22
- import type { BoardBootstrap, BoardConfig, Task, TaskComment } from '../types'
23
- import { ErrorCode, KanbanError } from '../errors'
24
- import { LOCAL_CAPABILITIES } from './capabilities'
25
- import type {
26
- CreateTaskInput,
27
- KanbanProvider,
28
- ProviderContext,
29
- ProviderSyncStatus,
30
- TaskListFilters,
31
- UpdateTaskInput,
32
- } from './types'
33
2
 
34
- function buildLocalConfig(
35
- db: Database,
36
- dbPath: string,
37
- discoveredAssignees = getDiscoveredAssignees(db),
38
- discoveredProjects = getDiscoveredProjects(db),
39
- ): BoardConfig {
40
- return {
41
- ...loadConfig(dbPath),
42
- provider: 'local',
43
- discoveredAssignees,
44
- discoveredProjects,
45
- }
46
- }
47
-
48
- export class LocalProvider implements KanbanProvider {
49
- readonly type = 'local' as const
50
-
51
- constructor(
52
- private readonly db: Database,
53
- private readonly dbPath = getDbPath(),
54
- ) {}
55
-
56
- private enrichTask(task: Task, commentCount?: number): Task {
57
- const revision = task.revision ?? 0
58
- const assignees = task.assignee ? [task.assignee] : []
59
- const labels = Array.isArray(task.labels) ? task.labels : []
60
- return {
61
- ...task,
62
- providerId: task.id,
63
- externalRef: task.id,
64
- url: null,
65
- assignees,
66
- labels,
67
- comment_count: commentCount ?? countComments(this.db, task.id),
68
- version: String(revision),
69
- source_updated_at: null,
70
- }
71
- }
72
-
73
- async getContext(): Promise<ProviderContext> {
74
- return {
75
- provider: this.type,
76
- capabilities: LOCAL_CAPABILITIES,
77
- team: null,
78
- }
79
- }
80
-
81
- async getBootstrap(): Promise<BoardBootstrap> {
82
- const metrics = getBoardMetrics(this.db)
83
- return {
84
- provider: this.type,
85
- capabilities: LOCAL_CAPABILITIES,
86
- board: await this.getBoard(),
87
- config: buildLocalConfig(this.db, this.dbPath, metrics.assignees, metrics.projects),
88
- metrics,
89
- activity: listActivity(this.db, { limit: 50 }),
90
- team: null,
91
- }
92
- }
93
-
94
- async getBoard() {
95
- const board = getBoardView(this.db)
96
- const counts = countCommentsByTask(this.db)
97
- return {
98
- columns: board.columns.map((column) => ({
99
- ...column,
100
- tasks: column.tasks.map((task) => this.enrichTask(task, counts.get(task.id) ?? 0)),
101
- })),
102
- }
103
- }
104
-
105
- async listColumns() {
106
- return listColumns(this.db)
107
- }
108
-
109
- async listTasks(filters: TaskListFilters = {}) {
110
- const counts = countCommentsByTask(this.db)
111
- return listTasks(this.db, filters).map((task) =>
112
- this.enrichTask(task, counts.get(task.id) ?? 0),
113
- )
114
- }
115
-
116
- async getTask(idOrRef: string) {
117
- return this.enrichTask(getTask(this.db, idOrRef))
118
- }
119
-
120
- async createTask(input: CreateTaskInput) {
121
- return this.enrichTask(addTask(this.db, input.title, input))
122
- }
123
-
124
- async updateTask(idOrRef: string, input: UpdateTaskInput) {
125
- if (input.expectedVersion !== undefined) {
126
- const current = getTask(this.db, idOrRef)
127
- const currentVersion = String(current.revision ?? 0)
128
- if (currentVersion !== input.expectedVersion) {
129
- throw new KanbanError(
130
- ErrorCode.CONFLICT,
131
- `Task ${idOrRef} was modified since you loaded it (expected version ${input.expectedVersion}, current ${currentVersion})`,
132
- )
133
- }
134
- }
135
- const updates: Omit<UpdateTaskInput, 'expectedVersion'> = { ...input }
136
- delete (updates as UpdateTaskInput).expectedVersion
137
- return this.enrichTask(updateTask(this.db, idOrRef, updates))
138
- }
139
-
140
- async moveTask(idOrRef: string, column: string) {
141
- return this.enrichTask(moveTask(this.db, idOrRef, column))
142
- }
143
-
144
- async deleteTask(idOrRef: string) {
145
- return this.enrichTask(deleteTask(this.db, idOrRef))
146
- }
147
-
148
- async listComments(idOrRef: string): Promise<TaskComment[]> {
149
- return listTaskComments(this.db, idOrRef)
150
- }
151
-
152
- async getComment(idOrRef: string, commentId: string): Promise<TaskComment> {
153
- return getTaskComment(this.db, idOrRef, commentId)
154
- }
155
-
156
- async comment(idOrRef: string, body: string): Promise<TaskComment> {
157
- return addComment(this.db, idOrRef, body)
158
- }
159
-
160
- async updateComment(idOrRef: string, commentId: string, body: string): Promise<TaskComment> {
161
- return updateTaskComment(this.db, idOrRef, commentId, body)
162
- }
163
-
164
- async getActivity(limit?: number, taskId?: string) {
165
- return listActivity(this.db, { limit, taskId })
166
- }
167
-
168
- async getMetrics() {
169
- return getBoardMetrics(this.db)
170
- }
171
-
172
- async getConfig(): Promise<BoardConfig> {
173
- return buildLocalConfig(this.db, this.dbPath)
174
- }
175
-
176
- async patchConfig(input: Partial<BoardConfig>) {
177
- const config = loadConfig(this.dbPath)
178
- if (input.members) config.members = input.members
179
- if (input.projects) config.projects = input.projects
180
- saveConfig(getConfigPath(this.dbPath), config)
181
- return this.getConfig()
182
- }
3
+ import { getDbPath } from '../db'
4
+ import { LocalProviderCore } from './local-core'
5
+ import { SqliteLocalStore } from './sqlite-local-store'
183
6
 
184
- async getSyncStatus(): Promise<ProviderSyncStatus | null> {
185
- return null
7
+ export class LocalProvider extends LocalProviderCore {
8
+ constructor(db: Database, dbPath = getDbPath()) {
9
+ super(new SqliteLocalStore(db, dbPath))
186
10
  }
187
11
  }
@@ -0,0 +1,10 @@
1
+ import type { Sql, TransactionSql } from 'postgres'
2
+
3
+ // postgres.js's TransactionSql does not extend Sql, so batch helpers accept either.
4
+ export type Exec = Sql | TransactionSql
5
+
6
+ // Bind a row array as a single jsonb parameter for jsonb_to_recordset batch
7
+ // statements. The cast works around sql.json's narrower JSONValue parameter type.
8
+ export function recordsetJson(sql: Exec, rows: unknown[]): ReturnType<Sql['json']> {
9
+ return sql.json(rows as never)
10
+ }