@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.
@@ -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,
@@ -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
- private async resolveAssigneeId(name?: string): Promise<string | undefined> {
322
- if (!name) return undefined
323
- return (await this.cache.findUserIdByName(name)) ?? undefined
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?: string): Promise<string | undefined> {
327
- if (!name) return undefined
328
- return (await this.cache.findProjectIdByName(name)) ?? undefined
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'] = (await this.resolveAssigneeId(input.assignee)) ?? null
503
+ updateInput['assigneeId'] = input.assignee
504
+ ? await this.resolveAssigneeId(input.assignee)
505
+ : null
473
506
  if (input.project !== undefined)
474
- updateInput['projectId'] = (await this.resolveProjectId(input.project)) ?? null
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
- await this.sync(true)
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
- await this.sync(true)
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
- console.warn(
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 { 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> {
@@ -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
+ }
@@ -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