@andypai/agent-kanban 0.6.2 → 0.6.3

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.
@@ -18,9 +18,14 @@ import {
18
18
  type WebhookResult,
19
19
  } from '../webhooks'
20
20
  import { LINEAR_CAPABILITIES } from './capabilities'
21
- import { LinearClient, resolveLabelIdsForCreate, type LinearComment } from './linear-client'
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,
@@ -61,6 +66,33 @@ function toLinearPriority(priority: Task['priority'] | undefined): number | unde
61
66
  }
62
67
  }
63
68
 
69
+ type CacheIssue = Parameters<LinearCachePort['upsertIssues']>[0][number]
70
+
71
+ // Map a Linear API issue to the cache upsert row. Shared by the bulk sync and
72
+ // the single-issue hydrate path so a read-after-write refresh caches exactly the
73
+ // same shape as a full sync.
74
+ function toCacheIssue(issue: LinearIssue): CacheIssue {
75
+ return {
76
+ id: issue.id,
77
+ identifier: issue.identifier,
78
+ title: issue.title,
79
+ description: issue.description ?? '',
80
+ priority: issue.priority ?? 0,
81
+ assigneeId: issue.assignee?.id ?? null,
82
+ assigneeName: issue.assignee?.name ?? null,
83
+ projectId: issue.project?.id ?? null,
84
+ projectName: issue.project?.name ?? null,
85
+ stateId: issue.state.id,
86
+ stateName: issue.state.name,
87
+ statePosition: issue.state.position,
88
+ labels: issue.labels ?? [],
89
+ commentCount: issue.commentCount,
90
+ url: issue.url ?? null,
91
+ createdAt: issue.createdAt,
92
+ updatedAt: issue.updatedAt,
93
+ }
94
+ }
95
+
64
96
  /**
65
97
  * Storage-agnostic cache/repository port the Linear core depends on. The SQLite
66
98
  * (linear.ts) and Postgres (postgres-linear.ts) backends each provide an
@@ -179,8 +211,10 @@ export class LinearProviderCore implements KanbanProvider {
179
211
  const now = Date.now()
180
212
  if (!force && lastSyncAtMs && now - lastSyncAtMs < this.pollingSyncIntervalMs) return
181
213
 
214
+ // `force` bypasses the poll throttle (above) for read-after-write freshness,
215
+ // but does NOT imply a full workspace reconcile — that stays on its own
216
+ // cadence so a single write isn't coupled to team-wide prune/refetch.
182
217
  const shouldFullSync =
183
- force ||
184
218
  !lastFullSyncAtMs ||
185
219
  !meta.lastIssueUpdatedAt ||
186
220
  now - lastFullSyncAtMs >= FULL_RECONCILIATION_INTERVAL_MS
@@ -198,27 +232,7 @@ export class LinearProviderCore implements KanbanProvider {
198
232
  await this.cache.replaceStates(team.states)
199
233
  await this.cache.upsertUsers(users)
200
234
  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
- )
235
+ await this.cache.upsertIssues(issues.map(toCacheIssue))
222
236
  if (shouldFullSync) {
223
237
  await this.cache.pruneIssues(issues.map((issue) => issue.id))
224
238
  }
@@ -304,6 +318,31 @@ export class LinearProviderCore implements KanbanProvider {
304
318
  return task
305
319
  }
306
320
 
321
+ // Read-after-write refresh of a single issue. Re-fetching just the mutated
322
+ // issue (and its history) keeps the post-write read fresh without paying for a
323
+ // full team sync(true)/prune on every update or move.
324
+ private async hydrateIssue(issueId: string): Promise<Task> {
325
+ const issue = await this.client.getIssue(issueId)
326
+ // Mirror the bulk sync's team scoping: it only caches issues from the
327
+ // configured team. If the issue vanished upstream or moved out of the team,
328
+ // drop the now out-of-scope local row instead of re-caching it, matching what
329
+ // a full reconcile would eventually prune.
330
+ if (!issue || (issue.teamId && issue.teamId !== (await this.resolvedTeamId()))) {
331
+ await this.cache.deleteIssue(issueId)
332
+ throw new KanbanError(ErrorCode.TASK_NOT_FOUND, `No task with id '${issueId}'`)
333
+ }
334
+ await this.cache.upsertIssues([toCacheIssue(issue)])
335
+ // Best-effort changelog ingest for the moved/updated issue so activity stays
336
+ // current; failures don't fail the write's read-after-write.
337
+ await this.ingestTeamHistory(
338
+ [issue.id],
339
+ (await this.cache.loadSyncMeta()).lastIssueUpdatedAt,
340
+ ).catch((err) => {
341
+ console.warn('[linear] issueHistory ingest failed:', err)
342
+ })
343
+ return this.resolveTask(issue.id)
344
+ }
345
+
307
346
  private async resolveState(column: string): Promise<Column> {
308
347
  const states = await this.cache.getCachedColumns()
309
348
  const match = states.find(
@@ -318,14 +357,25 @@ export class LinearProviderCore implements KanbanProvider {
318
357
  return match
319
358
  }
320
359
 
321
- private async resolveAssigneeId(name?: string): Promise<string | undefined> {
322
- if (!name) return undefined
323
- return (await this.cache.findUserIdByName(name)) ?? undefined
360
+ // Only invoked for non-empty names. A name that the cache cannot resolve is a
361
+ // hard error rather than a silent drop/clear: callers handle "not provided"
362
+ // (undefined) and "clear" (empty string) before calling this.
363
+ private async resolveAssigneeId(name: string): Promise<string> {
364
+ const id = await this.cache.findUserIdByName(name)
365
+ if (!id) {
366
+ providerUpstreamError(
367
+ `Linear assignee '${name}' was not found in the cached user list. Try 'kanban task list --assignee' to see cached names.`,
368
+ )
369
+ }
370
+ return id
324
371
  }
325
372
 
326
- private async resolveProjectId(name?: string): Promise<string | undefined> {
327
- if (!name) return undefined
328
- return (await this.cache.findProjectIdByName(name)) ?? undefined
373
+ private async resolveProjectId(name: string): Promise<string> {
374
+ const id = await this.cache.findProjectIdByName(name)
375
+ if (!id) {
376
+ providerUpstreamError(`Linear project '${name}' was not found in the cached project list.`)
377
+ }
378
+ return id
329
379
  }
330
380
 
331
381
  private toTaskComment(task: Task, comment: LinearComment): TaskComment {
@@ -423,35 +473,15 @@ export class LinearProviderCore implements KanbanProvider {
423
473
  title: input.title,
424
474
  description: input.description,
425
475
  priority: toLinearPriority(input.priority),
426
- assigneeId: await this.resolveAssigneeId(input.assignee),
427
- projectId: await this.resolveProjectId(input.project),
476
+ assigneeId: input.assignee ? await this.resolveAssigneeId(input.assignee) : undefined,
477
+ projectId: input.project ? await this.resolveProjectId(input.project) : undefined,
428
478
  labelIds,
429
479
  })
430
480
  if (!result.success || !result.issue) {
431
481
  throw new KanbanError(ErrorCode.PROVIDER_UPSTREAM_ERROR, 'Linear issue creation failed')
432
482
  }
433
483
  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
- ])
484
+ await this.cache.upsertIssues([toCacheIssue(issue)])
455
485
  return this.resolveTask(issue.id)
456
486
  }
457
487
 
@@ -469,9 +499,11 @@ export class LinearProviderCore implements KanbanProvider {
469
499
  if (input.description !== undefined) updateInput['description'] = input.description
470
500
  if (input.priority !== undefined) updateInput['priority'] = toLinearPriority(input.priority)
471
501
  if (input.assignee !== undefined)
472
- updateInput['assigneeId'] = (await this.resolveAssigneeId(input.assignee)) ?? null
502
+ updateInput['assigneeId'] = input.assignee
503
+ ? await this.resolveAssigneeId(input.assignee)
504
+ : null
473
505
  if (input.project !== undefined)
474
- updateInput['projectId'] = (await this.resolveProjectId(input.project)) ?? null
506
+ updateInput['projectId'] = input.project ? await this.resolveProjectId(input.project) : null
475
507
  if (input.metadata !== undefined) {
476
508
  unsupportedOperation('Linear mode does not support metadata updates')
477
509
  }
@@ -479,8 +511,7 @@ export class LinearProviderCore implements KanbanProvider {
479
511
  if (!result.success) {
480
512
  throw new KanbanError(ErrorCode.PROVIDER_UPSTREAM_ERROR, 'Linear issue update failed')
481
513
  }
482
- await this.sync(true)
483
- return this.resolveTask(task.providerId || task.id)
514
+ return this.hydrateIssue(task.providerId || task.id)
484
515
  }
485
516
 
486
517
  async moveTask(idOrRef: string, column: string): Promise<Task> {
@@ -491,8 +522,7 @@ export class LinearProviderCore implements KanbanProvider {
491
522
  if (!result.success) {
492
523
  throw new KanbanError(ErrorCode.PROVIDER_UPSTREAM_ERROR, 'Linear issue move failed')
493
524
  }
494
- await this.sync(true)
495
- return this.resolveTask(task.providerId || task.id)
525
+ return this.hydrateIssue(task.providerId || task.id)
496
526
  }
497
527
 
498
528
  async deleteTask(_idOrRef: string): Promise<Task> {
@@ -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 { selectDoneColumnIds, selectInProgressColumnIds } from '../column-roles'
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 { LOCAL_CAPABILITIES } from './capabilities'
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
- await this.sql`ALTER TABLE tasks ADD COLUMN IF NOT EXISTS labels TEXT NOT NULL DEFAULT '[]'`
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: LOCAL_CAPABILITIES, team: null }
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: LOCAL_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
- tasks = tasks.filter((task) => task.column_id === column.id)
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
- if (filters.priority) tasks = tasks.filter((task) => task.priority === filters.priority)
345
- if (filters.assignee) tasks = tasks.filter((task) => task.assignee === filters.assignee)
346
- if (filters.project) tasks = tasks.filter((task) => task.project === filters.project)
347
- if (filters.sort === 'title') tasks = [...tasks].sort((a, b) => a.title.localeCompare(b.title))
348
- if (filters.sort === 'updated')
349
- tasks = [...tasks].sort((a, b) => b.updated_at.localeCompare(a.updated_at))
350
- if (filters.limit) tasks = tasks.slice(0, filters.limit)
351
- return tasks
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
- const [comment] = await this.sql<TaskComment[]>`
530
- INSERT INTO comments (id, task_id, body, author, created_at, updated_at)
531
- VALUES (${id}, ${task.id}, ${body}, ${null}, ${timestamp}, ${timestamp})
532
- RETURNING *
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 [comment] = await this.sql<TaskComment[]>`
540
- UPDATE comments
541
- SET body = ${body}, updated_at = ${nowIso()}
542
- WHERE id = ${existing.id}
543
- RETURNING *
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
- // Classify columns by role (custom names / KANBAN_DEFAULT_COLUMNS) instead of
571
- // matching the literal 'done' / 'in-progress'. Kept in lockstep with the
572
- // SQLite copy in metrics.ts:getBoardMetrics.
573
- const columns = await this.sql<{ id: string; name: string; position: number }[]>`
574
- SELECT id, name, position FROM columns ORDER BY position
575
- `
576
- const doneColumnIds = selectDoneColumnIds(columns)
577
- const inProgressColumnIds = selectInProgressColumnIds(columns)
578
- const [completed] =
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 tasksByPriority = await this.sql<{ priority: string; count: string | number }[]>`
592
- SELECT priority, COUNT(*) AS count FROM tasks GROUP BY priority ORDER BY priority
593
- `
594
- const assignees = await this.discoveredAssignees()
595
- const projects = await this.discoveredProjects()
596
- const totalTasks = Number(total?.count ?? 0)
597
- const completedTasks = Number(completed?.count ?? 0)
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. Kept
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
- const inProgressNames = new Set(
626
- columns.filter((c) => inProgressColumnIds.includes(c.id)).map((c) => c.name),
627
- )
628
- const inProgressCount = tasksByColumn
629
- .filter((row) => inProgressNames.has(row.column_name))
630
- .reduce((sum, row) => sum + Number(row.count), 0)
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
- tasksCreatedThisWeek: Number(weekCount?.count ?? 0),
645
- inProgressCount,
646
- completionPercent: totalTasks === 0 ? 0 : Math.round((completedTasks / totalTasks) * 100),
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(input: Partial<BoardConfig>): Promise<BoardConfig> {
711
- await this.ready
712
- const config = await this.getConfig()
713
- return {
714
- ...config,
715
- members: input.members ?? config.members,
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> {
@@ -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
- return columns.length > 0 ? columns : undefined
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