@andypai/agent-kanban 0.6.2 → 0.6.4
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/package.json +1 -1
- package/src/__tests__/api.test.ts +48 -0
- package/src/__tests__/conflict.test.ts +25 -1
- package/src/__tests__/linear-provider-sync.test.ts +517 -40
- package/src/__tests__/mcp-core.test.ts +54 -0
- package/src/__tests__/mcp-server.test.ts +17 -0
- package/src/__tests__/metrics-spec.test.ts +121 -0
- package/src/__tests__/postgres-linear-provider.test.ts +10 -2
- package/src/__tests__/postgres-local-provider.test.ts +140 -0
- package/src/__tests__/tracker-config-columns.test.ts +23 -0
- package/src/__tests__/transport-input.test.ts +35 -0
- package/src/api.ts +35 -31
- package/src/commands/mcp.ts +2 -1
- package/src/db.ts +1 -1
- package/src/errors.ts +2 -0
- package/src/index.ts +6 -1
- package/src/mcp/core.ts +24 -2
- package/src/mcp/server.ts +2 -1
- package/src/mcp/types.ts +12 -1
- package/src/metrics-spec.ts +87 -0
- package/src/metrics.ts +29 -60
- package/src/providers/capabilities.ts +12 -0
- package/src/providers/jira-core.ts +3 -1
- package/src/providers/linear-client.ts +172 -95
- package/src/providers/linear-core.ts +92 -60
- package/src/providers/postgres-local.ts +136 -89
- package/src/providers/warn-once.ts +9 -0
- package/src/tracker-config.ts +17 -1
- package/src/transport-input.ts +30 -0
- package/src/version.ts +8 -0
- package/ui/dist/assets/index-Cigv8a9S.js +40 -0
- package/ui/dist/index.html +1 -1
- package/ui/dist/assets/index-65LcV0R7.js +0 -40
|
@@ -18,9 +18,14 @@ import {
|
|
|
18
18
|
type WebhookResult,
|
|
19
19
|
} from '../webhooks'
|
|
20
20
|
import { LINEAR_CAPABILITIES } from './capabilities'
|
|
21
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
LinearClient,
|
|
23
|
+
resolveLabelIdsForCreate,
|
|
24
|
+
type LinearComment,
|
|
25
|
+
type LinearIssue,
|
|
26
|
+
} from './linear-client'
|
|
22
27
|
import type { LinearActivityRow, LinearStateRow, LinearSyncMeta } from './linear-cache'
|
|
23
|
-
import { unsupportedOperation } from './errors'
|
|
28
|
+
import { providerUpstreamError, unsupportedOperation } from './errors'
|
|
24
29
|
import type {
|
|
25
30
|
CreateTaskInput,
|
|
26
31
|
KanbanProvider,
|
|
@@ -30,6 +35,7 @@ import type {
|
|
|
30
35
|
UpdateTaskInput,
|
|
31
36
|
} from './types'
|
|
32
37
|
import { DEFAULT_POLLING_SYNC_INTERVAL_MS } from '../sync-config'
|
|
38
|
+
import { warnOnce } from './warn-once'
|
|
33
39
|
|
|
34
40
|
const FULL_RECONCILIATION_INTERVAL_MS = 5 * 60_000
|
|
35
41
|
|
|
@@ -61,6 +67,33 @@ function toLinearPriority(priority: Task['priority'] | undefined): number | unde
|
|
|
61
67
|
}
|
|
62
68
|
}
|
|
63
69
|
|
|
70
|
+
type CacheIssue = Parameters<LinearCachePort['upsertIssues']>[0][number]
|
|
71
|
+
|
|
72
|
+
// Map a Linear API issue to the cache upsert row. Shared by the bulk sync and
|
|
73
|
+
// the single-issue hydrate path so a read-after-write refresh caches exactly the
|
|
74
|
+
// same shape as a full sync.
|
|
75
|
+
function toCacheIssue(issue: LinearIssue): CacheIssue {
|
|
76
|
+
return {
|
|
77
|
+
id: issue.id,
|
|
78
|
+
identifier: issue.identifier,
|
|
79
|
+
title: issue.title,
|
|
80
|
+
description: issue.description ?? '',
|
|
81
|
+
priority: issue.priority ?? 0,
|
|
82
|
+
assigneeId: issue.assignee?.id ?? null,
|
|
83
|
+
assigneeName: issue.assignee?.name ?? null,
|
|
84
|
+
projectId: issue.project?.id ?? null,
|
|
85
|
+
projectName: issue.project?.name ?? null,
|
|
86
|
+
stateId: issue.state.id,
|
|
87
|
+
stateName: issue.state.name,
|
|
88
|
+
statePosition: issue.state.position,
|
|
89
|
+
labels: issue.labels ?? [],
|
|
90
|
+
commentCount: issue.commentCount,
|
|
91
|
+
url: issue.url ?? null,
|
|
92
|
+
createdAt: issue.createdAt,
|
|
93
|
+
updatedAt: issue.updatedAt,
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
64
97
|
/**
|
|
65
98
|
* Storage-agnostic cache/repository port the Linear core depends on. The SQLite
|
|
66
99
|
* (linear.ts) and Postgres (postgres-linear.ts) backends each provide an
|
|
@@ -179,8 +212,10 @@ export class LinearProviderCore implements KanbanProvider {
|
|
|
179
212
|
const now = Date.now()
|
|
180
213
|
if (!force && lastSyncAtMs && now - lastSyncAtMs < this.pollingSyncIntervalMs) return
|
|
181
214
|
|
|
215
|
+
// `force` bypasses the poll throttle (above) for read-after-write freshness,
|
|
216
|
+
// but does NOT imply a full workspace reconcile — that stays on its own
|
|
217
|
+
// cadence so a single write isn't coupled to team-wide prune/refetch.
|
|
182
218
|
const shouldFullSync =
|
|
183
|
-
force ||
|
|
184
219
|
!lastFullSyncAtMs ||
|
|
185
220
|
!meta.lastIssueUpdatedAt ||
|
|
186
221
|
now - lastFullSyncAtMs >= FULL_RECONCILIATION_INTERVAL_MS
|
|
@@ -198,27 +233,7 @@ export class LinearProviderCore implements KanbanProvider {
|
|
|
198
233
|
await this.cache.replaceStates(team.states)
|
|
199
234
|
await this.cache.upsertUsers(users)
|
|
200
235
|
await this.cache.upsertProjects(projects)
|
|
201
|
-
await this.cache.upsertIssues(
|
|
202
|
-
issues.map((issue) => ({
|
|
203
|
-
id: issue.id,
|
|
204
|
-
identifier: issue.identifier,
|
|
205
|
-
title: issue.title,
|
|
206
|
-
description: issue.description ?? '',
|
|
207
|
-
priority: issue.priority ?? 0,
|
|
208
|
-
assigneeId: issue.assignee?.id ?? null,
|
|
209
|
-
assigneeName: issue.assignee?.name ?? null,
|
|
210
|
-
projectId: issue.project?.id ?? null,
|
|
211
|
-
projectName: issue.project?.name ?? null,
|
|
212
|
-
stateId: issue.state.id,
|
|
213
|
-
stateName: issue.state.name,
|
|
214
|
-
statePosition: issue.state.position,
|
|
215
|
-
labels: issue.labels ?? [],
|
|
216
|
-
commentCount: issue.commentCount,
|
|
217
|
-
url: issue.url ?? null,
|
|
218
|
-
createdAt: issue.createdAt,
|
|
219
|
-
updatedAt: issue.updatedAt,
|
|
220
|
-
})),
|
|
221
|
-
)
|
|
236
|
+
await this.cache.upsertIssues(issues.map(toCacheIssue))
|
|
222
237
|
if (shouldFullSync) {
|
|
223
238
|
await this.cache.pruneIssues(issues.map((issue) => issue.id))
|
|
224
239
|
}
|
|
@@ -304,6 +319,31 @@ export class LinearProviderCore implements KanbanProvider {
|
|
|
304
319
|
return task
|
|
305
320
|
}
|
|
306
321
|
|
|
322
|
+
// Read-after-write refresh of a single issue. Re-fetching just the mutated
|
|
323
|
+
// issue (and its history) keeps the post-write read fresh without paying for a
|
|
324
|
+
// full team sync(true)/prune on every update or move.
|
|
325
|
+
private async hydrateIssue(issueId: string): Promise<Task> {
|
|
326
|
+
const issue = await this.client.getIssue(issueId)
|
|
327
|
+
// Mirror the bulk sync's team scoping: it only caches issues from the
|
|
328
|
+
// configured team. If the issue vanished upstream or moved out of the team,
|
|
329
|
+
// drop the now out-of-scope local row instead of re-caching it, matching what
|
|
330
|
+
// a full reconcile would eventually prune.
|
|
331
|
+
if (!issue || (issue.teamId && issue.teamId !== (await this.resolvedTeamId()))) {
|
|
332
|
+
await this.cache.deleteIssue(issueId)
|
|
333
|
+
throw new KanbanError(ErrorCode.TASK_NOT_FOUND, `No task with id '${issueId}'`)
|
|
334
|
+
}
|
|
335
|
+
await this.cache.upsertIssues([toCacheIssue(issue)])
|
|
336
|
+
// Best-effort changelog ingest for the moved/updated issue so activity stays
|
|
337
|
+
// current; failures don't fail the write's read-after-write.
|
|
338
|
+
await this.ingestTeamHistory(
|
|
339
|
+
[issue.id],
|
|
340
|
+
(await this.cache.loadSyncMeta()).lastIssueUpdatedAt,
|
|
341
|
+
).catch((err) => {
|
|
342
|
+
console.warn('[linear] issueHistory ingest failed:', err)
|
|
343
|
+
})
|
|
344
|
+
return this.resolveTask(issue.id)
|
|
345
|
+
}
|
|
346
|
+
|
|
307
347
|
private async resolveState(column: string): Promise<Column> {
|
|
308
348
|
const states = await this.cache.getCachedColumns()
|
|
309
349
|
const match = states.find(
|
|
@@ -318,14 +358,25 @@ export class LinearProviderCore implements KanbanProvider {
|
|
|
318
358
|
return match
|
|
319
359
|
}
|
|
320
360
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
361
|
+
// Only invoked for non-empty names. A name that the cache cannot resolve is a
|
|
362
|
+
// hard error rather than a silent drop/clear: callers handle "not provided"
|
|
363
|
+
// (undefined) and "clear" (empty string) before calling this.
|
|
364
|
+
private async resolveAssigneeId(name: string): Promise<string> {
|
|
365
|
+
const id = await this.cache.findUserIdByName(name)
|
|
366
|
+
if (!id) {
|
|
367
|
+
providerUpstreamError(
|
|
368
|
+
`Linear assignee '${name}' was not found in the cached user list. Try 'kanban task list --assignee' to see cached names.`,
|
|
369
|
+
)
|
|
370
|
+
}
|
|
371
|
+
return id
|
|
324
372
|
}
|
|
325
373
|
|
|
326
|
-
private async resolveProjectId(name
|
|
327
|
-
|
|
328
|
-
|
|
374
|
+
private async resolveProjectId(name: string): Promise<string> {
|
|
375
|
+
const id = await this.cache.findProjectIdByName(name)
|
|
376
|
+
if (!id) {
|
|
377
|
+
providerUpstreamError(`Linear project '${name}' was not found in the cached project list.`)
|
|
378
|
+
}
|
|
379
|
+
return id
|
|
329
380
|
}
|
|
330
381
|
|
|
331
382
|
private toTaskComment(task: Task, comment: LinearComment): TaskComment {
|
|
@@ -423,35 +474,15 @@ export class LinearProviderCore implements KanbanProvider {
|
|
|
423
474
|
title: input.title,
|
|
424
475
|
description: input.description,
|
|
425
476
|
priority: toLinearPriority(input.priority),
|
|
426
|
-
assigneeId: await this.resolveAssigneeId(input.assignee),
|
|
427
|
-
projectId: await this.resolveProjectId(input.project),
|
|
477
|
+
assigneeId: input.assignee ? await this.resolveAssigneeId(input.assignee) : undefined,
|
|
478
|
+
projectId: input.project ? await this.resolveProjectId(input.project) : undefined,
|
|
428
479
|
labelIds,
|
|
429
480
|
})
|
|
430
481
|
if (!result.success || !result.issue) {
|
|
431
482
|
throw new KanbanError(ErrorCode.PROVIDER_UPSTREAM_ERROR, 'Linear issue creation failed')
|
|
432
483
|
}
|
|
433
484
|
const issue = result.issue
|
|
434
|
-
await this.cache.upsertIssues([
|
|
435
|
-
{
|
|
436
|
-
id: issue.id,
|
|
437
|
-
identifier: issue.identifier,
|
|
438
|
-
title: issue.title,
|
|
439
|
-
description: issue.description ?? '',
|
|
440
|
-
priority: issue.priority ?? 0,
|
|
441
|
-
assigneeId: issue.assignee?.id ?? null,
|
|
442
|
-
assigneeName: issue.assignee?.name ?? issue.assignee?.displayName ?? '',
|
|
443
|
-
projectId: issue.project?.id ?? null,
|
|
444
|
-
projectName: issue.project?.name ?? '',
|
|
445
|
-
stateId: issue.state.id,
|
|
446
|
-
stateName: issue.state.name,
|
|
447
|
-
statePosition: issue.state.position,
|
|
448
|
-
labels: issue.labels ?? [],
|
|
449
|
-
commentCount: issue.commentCount,
|
|
450
|
-
url: issue.url ?? null,
|
|
451
|
-
createdAt: issue.createdAt,
|
|
452
|
-
updatedAt: issue.updatedAt,
|
|
453
|
-
},
|
|
454
|
-
])
|
|
485
|
+
await this.cache.upsertIssues([toCacheIssue(issue)])
|
|
455
486
|
return this.resolveTask(issue.id)
|
|
456
487
|
}
|
|
457
488
|
|
|
@@ -469,9 +500,11 @@ export class LinearProviderCore implements KanbanProvider {
|
|
|
469
500
|
if (input.description !== undefined) updateInput['description'] = input.description
|
|
470
501
|
if (input.priority !== undefined) updateInput['priority'] = toLinearPriority(input.priority)
|
|
471
502
|
if (input.assignee !== undefined)
|
|
472
|
-
updateInput['assigneeId'] =
|
|
503
|
+
updateInput['assigneeId'] = input.assignee
|
|
504
|
+
? await this.resolveAssigneeId(input.assignee)
|
|
505
|
+
: null
|
|
473
506
|
if (input.project !== undefined)
|
|
474
|
-
updateInput['projectId'] =
|
|
507
|
+
updateInput['projectId'] = input.project ? await this.resolveProjectId(input.project) : null
|
|
475
508
|
if (input.metadata !== undefined) {
|
|
476
509
|
unsupportedOperation('Linear mode does not support metadata updates')
|
|
477
510
|
}
|
|
@@ -479,8 +512,7 @@ export class LinearProviderCore implements KanbanProvider {
|
|
|
479
512
|
if (!result.success) {
|
|
480
513
|
throw new KanbanError(ErrorCode.PROVIDER_UPSTREAM_ERROR, 'Linear issue update failed')
|
|
481
514
|
}
|
|
482
|
-
|
|
483
|
-
return this.resolveTask(task.providerId || task.id)
|
|
515
|
+
return this.hydrateIssue(task.providerId || task.id)
|
|
484
516
|
}
|
|
485
517
|
|
|
486
518
|
async moveTask(idOrRef: string, column: string): Promise<Task> {
|
|
@@ -491,8 +523,7 @@ export class LinearProviderCore implements KanbanProvider {
|
|
|
491
523
|
if (!result.success) {
|
|
492
524
|
throw new KanbanError(ErrorCode.PROVIDER_UPSTREAM_ERROR, 'Linear issue move failed')
|
|
493
525
|
}
|
|
494
|
-
|
|
495
|
-
return this.resolveTask(task.providerId || task.id)
|
|
526
|
+
return this.hydrateIssue(task.providerId || task.id)
|
|
496
527
|
}
|
|
497
528
|
|
|
498
529
|
async deleteTask(_idOrRef: string): Promise<Task> {
|
|
@@ -580,7 +611,8 @@ export class LinearProviderCore implements KanbanProvider {
|
|
|
580
611
|
// SQLite calls it directly via the default handleWebhook above.
|
|
581
612
|
protected async handleWebhookCore(payload: WebhookRequest): Promise<WebhookResult> {
|
|
582
613
|
if (!process.env['LINEAR_WEBHOOK_SECRET']) {
|
|
583
|
-
|
|
614
|
+
warnOnce(
|
|
615
|
+
'linear-webhook-open-dev-mode',
|
|
584
616
|
'[linear] LINEAR_WEBHOOK_SECRET is not set — accepting webhook without signature verification (open dev mode)',
|
|
585
617
|
)
|
|
586
618
|
}
|
|
@@ -2,7 +2,7 @@ import type { Sql, TransactionSql } from 'postgres'
|
|
|
2
2
|
|
|
3
3
|
import { ErrorCode, KanbanError } from '../errors'
|
|
4
4
|
import { generateId } from '../id'
|
|
5
|
-
import {
|
|
5
|
+
import { assembleBoardMetrics, classifyColumnRoles } from '../metrics-spec'
|
|
6
6
|
import type {
|
|
7
7
|
ActivityEntry,
|
|
8
8
|
BoardBootstrap,
|
|
@@ -15,7 +15,8 @@ import type {
|
|
|
15
15
|
TaskComment,
|
|
16
16
|
TaskWithColumn,
|
|
17
17
|
} from '../types'
|
|
18
|
-
import {
|
|
18
|
+
import { POSTGRES_LOCAL_CAPABILITIES } from './capabilities'
|
|
19
|
+
import { unsupportedOperation } from './errors'
|
|
19
20
|
import type {
|
|
20
21
|
CreateTaskInput,
|
|
21
22
|
KanbanProvider,
|
|
@@ -170,7 +171,10 @@ export class PostgresLocalProvider implements KanbanProvider {
|
|
|
170
171
|
exited_at TEXT
|
|
171
172
|
)
|
|
172
173
|
`
|
|
173
|
-
|
|
174
|
+
// Run column migrations before creating indexes — pre-existing databases
|
|
175
|
+
// miss columns added after the original tasks table, and CREATE TABLE IF NOT
|
|
176
|
+
// EXISTS won't add them (parity with SQLite migrateSchema).
|
|
177
|
+
await this.migrateTasksTable()
|
|
174
178
|
await this.sql`CREATE INDEX IF NOT EXISTS idx_tasks_column_id ON tasks(column_id)`
|
|
175
179
|
await this.sql`CREATE INDEX IF NOT EXISTS idx_tasks_priority ON tasks(priority)`
|
|
176
180
|
await this.sql`CREATE INDEX IF NOT EXISTS idx_tasks_assignee ON tasks(assignee)`
|
|
@@ -182,6 +186,14 @@ export class PostgresLocalProvider implements KanbanProvider {
|
|
|
182
186
|
.sql`CREATE INDEX IF NOT EXISTS idx_column_time_task_id ON column_time_tracking(task_id)`
|
|
183
187
|
}
|
|
184
188
|
|
|
189
|
+
// Backfill columns added after the original tasks table shipped. Keep this in
|
|
190
|
+
// lockstep with src/db.ts:migrateSchema (project, labels, revision).
|
|
191
|
+
private async migrateTasksTable(): Promise<void> {
|
|
192
|
+
await this.sql`ALTER TABLE tasks ADD COLUMN IF NOT EXISTS project TEXT NOT NULL DEFAULT ''`
|
|
193
|
+
await this.sql`ALTER TABLE tasks ADD COLUMN IF NOT EXISTS labels TEXT NOT NULL DEFAULT '[]'`
|
|
194
|
+
await this.sql`ALTER TABLE tasks ADD COLUMN IF NOT EXISTS revision INTEGER NOT NULL DEFAULT 0`
|
|
195
|
+
}
|
|
196
|
+
|
|
185
197
|
private async seedDefaultColumns(): Promise<void> {
|
|
186
198
|
const [row] = await this.sql<
|
|
187
199
|
{ count: string | number }[]
|
|
@@ -284,7 +296,7 @@ export class PostgresLocalProvider implements KanbanProvider {
|
|
|
284
296
|
|
|
285
297
|
async getContext(): Promise<ProviderContext> {
|
|
286
298
|
await this.ready
|
|
287
|
-
return { provider: this.type, capabilities:
|
|
299
|
+
return { provider: this.type, capabilities: POSTGRES_LOCAL_CAPABILITIES, team: null }
|
|
288
300
|
}
|
|
289
301
|
|
|
290
302
|
async getBootstrap(): Promise<BoardBootstrap> {
|
|
@@ -292,7 +304,7 @@ export class PostgresLocalProvider implements KanbanProvider {
|
|
|
292
304
|
const metrics = await this.getMetrics()
|
|
293
305
|
return {
|
|
294
306
|
provider: this.type,
|
|
295
|
-
capabilities:
|
|
307
|
+
capabilities: POSTGRES_LOCAL_CAPABILITIES,
|
|
296
308
|
board: await this.getBoard(),
|
|
297
309
|
config: await this.getConfig(),
|
|
298
310
|
metrics,
|
|
@@ -328,27 +340,44 @@ export class PostgresLocalProvider implements KanbanProvider {
|
|
|
328
340
|
|
|
329
341
|
async listTasks(filters: TaskListFilters = {}): Promise<Task[]> {
|
|
330
342
|
await this.ready
|
|
331
|
-
const rows = await this.sql<TaskRow[]>`
|
|
332
|
-
SELECT tasks.*, columns.name AS column_name
|
|
333
|
-
FROM tasks
|
|
334
|
-
JOIN columns ON columns.id = tasks.column_id
|
|
335
|
-
ORDER BY tasks.created_at
|
|
336
|
-
`
|
|
337
|
-
const counts = await this.commentCountsByTask()
|
|
338
|
-
let tasks = rows.map((task) => this.enrichTask(task, counts.get(task.id) ?? 0))
|
|
339
343
|
|
|
344
|
+
// Push filtering, ordering, and the row cap into SQL instead of loading the
|
|
345
|
+
// whole table and a full comments group-by to filter in JS. Mirrors the
|
|
346
|
+
// SQLite path (db.ts:listTasks) so both backends share filter/sort semantics.
|
|
347
|
+
const conditions = []
|
|
340
348
|
if (filters.column) {
|
|
341
349
|
const column = await this.resolveColumn(filters.column)
|
|
342
|
-
|
|
350
|
+
conditions.push(this.sql`tasks.column_id = ${column.id}`)
|
|
351
|
+
}
|
|
352
|
+
if (filters.priority) conditions.push(this.sql`tasks.priority = ${filters.priority}`)
|
|
353
|
+
if (filters.assignee) conditions.push(this.sql`tasks.assignee = ${filters.assignee}`)
|
|
354
|
+
if (filters.project) conditions.push(this.sql`tasks.project = ${filters.project}`)
|
|
355
|
+
const where = conditions.length
|
|
356
|
+
? conditions.reduce((acc, cond) => this.sql`${acc} AND ${cond}`)
|
|
357
|
+
: this.sql`TRUE`
|
|
358
|
+
|
|
359
|
+
// Whitelisted ORDER BY fragments; the sort key never reaches SQL as data.
|
|
360
|
+
const orderByMap: Record<string, ReturnType<typeof this.sql>> = {
|
|
361
|
+
priority: this
|
|
362
|
+
.sql`CASE tasks.priority WHEN 'urgent' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 END`,
|
|
363
|
+
created: this.sql`tasks.created_at`,
|
|
364
|
+
updated: this.sql`tasks.updated_at`,
|
|
365
|
+
position: this.sql`tasks.position`,
|
|
366
|
+
title: this.sql`tasks.title`,
|
|
343
367
|
}
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
368
|
+
const orderBy = orderByMap[filters.sort ?? 'position'] ?? orderByMap['position']!
|
|
369
|
+
const limit = filters.limit ? this.sql`LIMIT ${filters.limit}` : this.sql``
|
|
370
|
+
|
|
371
|
+
const rows = await this.sql<Array<TaskRow & { comment_count: string | number }>>`
|
|
372
|
+
SELECT tasks.*, columns.name AS column_name,
|
|
373
|
+
(SELECT COUNT(*) FROM comments WHERE comments.task_id = tasks.id) AS comment_count
|
|
374
|
+
FROM tasks
|
|
375
|
+
JOIN columns ON columns.id = tasks.column_id
|
|
376
|
+
WHERE ${where}
|
|
377
|
+
ORDER BY ${orderBy}
|
|
378
|
+
${limit}
|
|
379
|
+
`
|
|
380
|
+
return rows.map((row) => this.enrichTask(row, Number(row.comment_count ?? 0)))
|
|
352
381
|
}
|
|
353
382
|
|
|
354
383
|
async getTask(idOrRef: string): Promise<Task> {
|
|
@@ -448,6 +477,29 @@ export class PostgresLocalProvider implements KanbanProvider {
|
|
|
448
477
|
tx,
|
|
449
478
|
)
|
|
450
479
|
}
|
|
480
|
+
if (input.project !== undefined && input.project !== current.project) {
|
|
481
|
+
await this.insertActivity(
|
|
482
|
+
current.id,
|
|
483
|
+
'updated',
|
|
484
|
+
'project',
|
|
485
|
+
current.project || null,
|
|
486
|
+
input.project,
|
|
487
|
+
tx,
|
|
488
|
+
)
|
|
489
|
+
}
|
|
490
|
+
if (input.description !== undefined && input.description !== current.description) {
|
|
491
|
+
await this.insertActivity(
|
|
492
|
+
current.id,
|
|
493
|
+
'updated',
|
|
494
|
+
'description',
|
|
495
|
+
current.description,
|
|
496
|
+
input.description,
|
|
497
|
+
tx,
|
|
498
|
+
)
|
|
499
|
+
}
|
|
500
|
+
if (metadata !== undefined && metadata !== current.metadata) {
|
|
501
|
+
await this.insertActivity(current.id, 'updated', 'metadata', current.metadata, metadata, tx)
|
|
502
|
+
}
|
|
451
503
|
})
|
|
452
504
|
return this.getTask(current.id)
|
|
453
505
|
}
|
|
@@ -526,22 +578,33 @@ export class PostgresLocalProvider implements KanbanProvider {
|
|
|
526
578
|
const task = await this.requireTask(idOrRef)
|
|
527
579
|
const id = generateId('cm')
|
|
528
580
|
const timestamp = nowIso()
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
581
|
+
// Comment writes are part of task history (parity with SQLite addComment):
|
|
582
|
+
// insert and activity in one transaction so they commit or roll back together.
|
|
583
|
+
const comment = await this.sql.begin(async (tx) => {
|
|
584
|
+
const [row] = await tx<TaskComment[]>`
|
|
585
|
+
INSERT INTO comments (id, task_id, body, author, created_at, updated_at)
|
|
586
|
+
VALUES (${id}, ${task.id}, ${body}, ${null}, ${timestamp}, ${timestamp})
|
|
587
|
+
RETURNING *
|
|
588
|
+
`
|
|
589
|
+
await this.insertActivity(task.id, 'updated', 'comment', null, body, tx)
|
|
590
|
+
return row
|
|
591
|
+
})
|
|
534
592
|
return comment!
|
|
535
593
|
}
|
|
536
594
|
|
|
537
595
|
async updateComment(idOrRef: string, commentId: string, body: string): Promise<TaskComment> {
|
|
538
596
|
const existing = await this.getComment(idOrRef, commentId)
|
|
539
|
-
const
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
597
|
+
const timestamp = nowIso()
|
|
598
|
+
const comment = await this.sql.begin(async (tx) => {
|
|
599
|
+
const [row] = await tx<TaskComment[]>`
|
|
600
|
+
UPDATE comments
|
|
601
|
+
SET body = ${body}, updated_at = ${timestamp}
|
|
602
|
+
WHERE id = ${existing.id}
|
|
603
|
+
RETURNING *
|
|
604
|
+
`
|
|
605
|
+
await this.insertActivity(existing.task_id, 'updated', 'comment', existing.body, body, tx)
|
|
606
|
+
return row
|
|
607
|
+
})
|
|
545
608
|
return comment!
|
|
546
609
|
}
|
|
547
610
|
|
|
@@ -567,39 +630,39 @@ export class PostgresLocalProvider implements KanbanProvider {
|
|
|
567
630
|
const [total] = await this.sql<
|
|
568
631
|
{ count: string | number }[]
|
|
569
632
|
>`SELECT COUNT(*) AS count FROM tasks`
|
|
570
|
-
|
|
571
|
-
//
|
|
572
|
-
//
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
doneColumnIds.length > 0
|
|
580
|
-
? await this.sql<{ count: string | number }[]>`
|
|
581
|
-
SELECT COUNT(*) AS count FROM tasks WHERE column_id IN ${this.sql(doneColumnIds)}
|
|
582
|
-
`
|
|
583
|
-
: [{ count: 0 }]
|
|
584
|
-
const tasksByColumn = await this.sql<{ column_name: string; count: string | number }[]>`
|
|
585
|
-
SELECT columns.name AS column_name, COUNT(tasks.id) AS count
|
|
633
|
+
|
|
634
|
+
// Gather raw aggregates with Postgres SQL; metrics-spec owns the derived
|
|
635
|
+
// fields (role classification, completion math, priority ordering) so both
|
|
636
|
+
// backends stay identical without manual lockstep.
|
|
637
|
+
const columnRows = await this.sql<
|
|
638
|
+
{ id: string; name: string; position: number; count: string | number }[]
|
|
639
|
+
>`
|
|
640
|
+
SELECT columns.id AS id, columns.name AS name, columns.position AS position,
|
|
641
|
+
COUNT(tasks.id) AS count
|
|
586
642
|
FROM columns
|
|
587
643
|
LEFT JOIN tasks ON tasks.column_id = columns.id
|
|
588
644
|
GROUP BY columns.id, columns.name, columns.position
|
|
589
645
|
ORDER BY columns.position
|
|
590
646
|
`
|
|
591
|
-
const
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
647
|
+
const columnCounts = columnRows.map((row) => ({
|
|
648
|
+
id: row.id,
|
|
649
|
+
name: row.name,
|
|
650
|
+
position: Number(row.position),
|
|
651
|
+
count: Number(row.count),
|
|
652
|
+
}))
|
|
653
|
+
// Done ids are needed up front to scope the average-completion query.
|
|
654
|
+
const { doneColumnIds } = classifyColumnRoles(columnCounts)
|
|
655
|
+
|
|
656
|
+
const priorityCounts = (
|
|
657
|
+
await this.sql<{ priority: string; count: string | number }[]>`
|
|
658
|
+
SELECT priority, COUNT(*) AS count FROM tasks GROUP BY priority
|
|
659
|
+
`
|
|
660
|
+
).map((row) => ({ priority: row.priority, count: Number(row.count) }))
|
|
661
|
+
|
|
598
662
|
// Average completion time = first tracked entry (creation) -> first time the
|
|
599
663
|
// task entered Done. Using the Done *entry* (not exit) counts tasks resting
|
|
600
664
|
// in Done; MIN() handles tasks that re-enter Done. Cast to timestamptz so the
|
|
601
|
-
// ISO-UTC strings compare correctly regardless of the server timezone.
|
|
602
|
-
// in lockstep with the SQLite copy in metrics.ts:getBoardMetrics.
|
|
665
|
+
// ISO-UTC strings compare correctly regardless of the server timezone.
|
|
603
666
|
const [avgResult] =
|
|
604
667
|
doneColumnIds.length > 0
|
|
605
668
|
? await this.sql<{ avg_hours: string | number | null }[]>`
|
|
@@ -622,31 +685,17 @@ export class PostgresLocalProvider implements KanbanProvider {
|
|
|
622
685
|
SELECT COUNT(*) AS count FROM tasks
|
|
623
686
|
WHERE created_at::timestamptz >= NOW() - INTERVAL '7 days'
|
|
624
687
|
`
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
return {
|
|
632
|
-
tasksByColumn: tasksByColumn.map((row) => ({
|
|
633
|
-
column_name: row.column_name,
|
|
634
|
-
count: Number(row.count),
|
|
635
|
-
})),
|
|
636
|
-
tasksByPriority: tasksByPriority.map((row) => ({
|
|
637
|
-
priority: row.priority,
|
|
638
|
-
count: Number(row.count),
|
|
639
|
-
})),
|
|
640
|
-
totalTasks,
|
|
641
|
-
completedTasks,
|
|
688
|
+
|
|
689
|
+
return assembleBoardMetrics({
|
|
690
|
+
columnCounts,
|
|
691
|
+
priorityCounts,
|
|
692
|
+
totalTasks: Number(total?.count ?? 0),
|
|
693
|
+
tasksCreatedThisWeek: Number(weekCount?.count ?? 0),
|
|
642
694
|
avgCompletionHours: avgResult?.avg_hours != null ? Number(avgResult.avg_hours) : null,
|
|
643
695
|
recentActivity: await this.getActivity(20),
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
assignees,
|
|
648
|
-
projects,
|
|
649
|
-
}
|
|
696
|
+
assignees: await this.discoveredAssignees(),
|
|
697
|
+
projects: await this.discoveredProjects(),
|
|
698
|
+
})
|
|
650
699
|
}
|
|
651
700
|
|
|
652
701
|
private async discoveredAssignees(): Promise<string[]> {
|
|
@@ -707,14 +756,12 @@ export class PostgresLocalProvider implements KanbanProvider {
|
|
|
707
756
|
}
|
|
708
757
|
}
|
|
709
758
|
|
|
710
|
-
async patchConfig(
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
projects: input.projects ?? config.projects,
|
|
717
|
-
}
|
|
759
|
+
async patchConfig(_input: Partial<BoardConfig>): Promise<BoardConfig> {
|
|
760
|
+
// No persistent config repository exists for Postgres-local (getConfig is
|
|
761
|
+
// reconstructed from task data). Rather than silently merge-and-return
|
|
762
|
+
// without persisting, advertise the capability honestly (configEdit:false)
|
|
763
|
+
// and fail loudly so the HTTP API and CLI agree.
|
|
764
|
+
unsupportedOperation('Editing board config is not supported with KANBAN_STORAGE=postgres')
|
|
718
765
|
}
|
|
719
766
|
|
|
720
767
|
async getSyncStatus(): Promise<ProviderSyncStatus | null> {
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
const warnedKeys = new Set<string>()
|
|
2
|
+
|
|
3
|
+
// Emit an informational warning at most once per process for a given key, so
|
|
4
|
+
// repeated hot paths (e.g. inbound webhooks) don't flood the dev console.
|
|
5
|
+
export function warnOnce(key: string, message: string): void {
|
|
6
|
+
if (warnedKeys.has(key)) return
|
|
7
|
+
warnedKeys.add(key)
|
|
8
|
+
console.warn(message)
|
|
9
|
+
}
|
package/src/tracker-config.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ErrorCode, KanbanError } from './errors'
|
|
1
2
|
import { providerNotConfigured } from './providers/errors'
|
|
2
3
|
import { resolvePollingSyncIntervalMs } from './sync-config'
|
|
3
4
|
|
|
@@ -100,5 +101,20 @@ function defaultColumnsFromEnv(env: Record<string, string | undefined>): string[
|
|
|
100
101
|
.split(',')
|
|
101
102
|
.map((name) => name.trim())
|
|
102
103
|
.filter(Boolean)
|
|
103
|
-
|
|
104
|
+
if (columns.length === 0) return undefined
|
|
105
|
+
// Column resolution is case-insensitive (LOWER(name)), and interactive column
|
|
106
|
+
// creation rejects case-insensitive duplicates. Reject them here too so the
|
|
107
|
+
// config path can't seed an ambiguous board state the interactive path forbids.
|
|
108
|
+
const seen = new Set<string>()
|
|
109
|
+
for (const name of columns) {
|
|
110
|
+
const key = name.toLowerCase()
|
|
111
|
+
if (seen.has(key)) {
|
|
112
|
+
throw new KanbanError(
|
|
113
|
+
ErrorCode.INVALID_CONFIG,
|
|
114
|
+
`KANBAN_DEFAULT_COLUMNS contains a duplicate column name (case-insensitive): '${name}'`,
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
seen.add(key)
|
|
118
|
+
}
|
|
119
|
+
return columns
|
|
104
120
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { ErrorCode, KanbanError } from './errors'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Parse an optional positive integer supplied through a transport boundary
|
|
5
|
+
* (HTTP query string, CLI flag). Returns `undefined` when the value is absent,
|
|
6
|
+
* and throws a `KanbanError(INVALID_ARGUMENT)` for anything that is not a
|
|
7
|
+
* positive integer — `NaN`, negatives, zero, decimals, and trailing-garbage
|
|
8
|
+
* strings like `"5abc"`. Centralizing this keeps invalid limits from leaking
|
|
9
|
+
* into provider logic, where they would otherwise produce inconsistent
|
|
10
|
+
* behavior across SQL `LIMIT`, `Array.prototype.slice`, and Postgres.
|
|
11
|
+
*/
|
|
12
|
+
export function parsePositiveInt(
|
|
13
|
+
value: string | null | undefined,
|
|
14
|
+
field = 'limit',
|
|
15
|
+
): number | undefined {
|
|
16
|
+
if (value === null || value === undefined) return undefined
|
|
17
|
+
const trimmed = value.trim()
|
|
18
|
+
if (trimmed === '') return undefined
|
|
19
|
+
// Plain decimal digits only — reject signs, decimals, and exponent forms like
|
|
20
|
+
// "1e100" that Number() would otherwise treat as integers and let escape into
|
|
21
|
+
// SQL `LIMIT`. Cap at MAX_SAFE_INTEGER so the parsed value stays exact.
|
|
22
|
+
const parsed = /^\d+$/.test(trimmed) ? Number(trimmed) : NaN
|
|
23
|
+
if (!Number.isInteger(parsed) || parsed <= 0 || parsed > Number.MAX_SAFE_INTEGER) {
|
|
24
|
+
throw new KanbanError(
|
|
25
|
+
ErrorCode.INVALID_ARGUMENT,
|
|
26
|
+
`${field} must be a positive integer (received '${value}')`,
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
return parsed
|
|
30
|
+
}
|
package/src/version.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import packageJson from '../package.json'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Single source of truth for the build/package version. Import this anywhere a
|
|
5
|
+
* release version is reported (e.g. MCP server metadata) so transports never
|
|
6
|
+
* advertise a hard-coded version that drifts from package.json.
|
|
7
|
+
*/
|
|
8
|
+
export const VERSION: string = packageJson.version
|