@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/src/mcp/types.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { ToolAnnotations } from '@modelcontextprotocol/sdk/types.js'
2
2
  import type { JsonSchemaType } from '@modelcontextprotocol/sdk/validation'
3
- import type { TaskComment } from '../types'
3
+ import type { Task, TaskComment } from '../types'
4
4
  import type { TrackerMcpError, TrackerMcpErrorCode } from './errors'
5
5
 
6
6
  export type TrackerMcpAuthResolver<TScope> = (ctx: {
@@ -20,6 +20,17 @@ export interface TrackerMcpPolicy<TScope> {
20
20
  ): Promise<void> | void
21
21
  canMoveTicket(scope: TScope, ticketId: string, destinationColumn: string): Promise<void> | void
22
22
  filterComment?(scope: TScope, comment: TaskComment): Promise<boolean> | boolean
23
+ /**
24
+ * Gate the aggregate board read. Throw to deny access to the whole board.
25
+ * Omit to allow board reads (individual tickets can still be hidden via `filterTask`).
26
+ */
27
+ canReadBoard?(scope: TScope): Promise<void> | void
28
+ /**
29
+ * Decide whether a single board task is visible to this scope. Return `false`
30
+ * to drop it from `getBoard`. Without this, the board exposes every ticket,
31
+ * bypassing per-ticket `canReadTicket` gates.
32
+ */
33
+ filterTask?(scope: TScope, task: Task): Promise<boolean> | boolean
23
34
  }
24
35
 
25
36
  export interface TrackerMcpHooks<TScope> {
@@ -0,0 +1,87 @@
1
+ import type { ActivityEntry, BoardMetrics } from './types'
2
+ import {
3
+ type ClassifiableColumn,
4
+ selectDoneColumnIds,
5
+ selectInProgressColumnIds,
6
+ } from './column-roles'
7
+
8
+ // One source of truth for how raw board aggregates become BoardMetrics. The
9
+ // SQLite (metrics.ts) and Postgres (postgres-local.ts) backends each gather the
10
+ // primitive rows with their own dialect, then feed them through this assembler
11
+ // so the derived fields — done/in-progress classification, completion math, and
12
+ // priority ordering — can never drift between backends.
13
+
14
+ const PRIORITY_RANK: Record<string, number> = { urgent: 0, high: 1, medium: 2, low: 3 }
15
+
16
+ /** A column plus how many tasks currently sit in it. */
17
+ export interface MetricsColumnCount extends ClassifiableColumn {
18
+ count: number
19
+ }
20
+
21
+ export interface MetricsInputs {
22
+ /** Every column with its live task count, in display (position) order. */
23
+ columnCounts: MetricsColumnCount[]
24
+ /** Raw per-priority counts; ordering is normalized here. */
25
+ priorityCounts: Array<{ priority: string; count: number }>
26
+ totalTasks: number
27
+ tasksCreatedThisWeek: number
28
+ /** Average creation→Done hours, or null when no Done column / no completions. */
29
+ avgCompletionHours: number | null
30
+ recentActivity: ActivityEntry[]
31
+ assignees: string[]
32
+ projects: string[]
33
+ }
34
+
35
+ /**
36
+ * Classify a board's columns into done / in-progress roles. Backends call this
37
+ * to scope their average-completion SQL (which needs the done column ids before
38
+ * querying column_time_tracking); assembleBoardMetrics() reclassifies from the
39
+ * same column set so the two never disagree.
40
+ */
41
+ export function classifyColumnRoles(columns: ClassifiableColumn[]): {
42
+ doneColumnIds: string[]
43
+ inProgressColumnIds: string[]
44
+ } {
45
+ return {
46
+ doneColumnIds: selectDoneColumnIds(columns),
47
+ inProgressColumnIds: selectInProgressColumnIds(columns),
48
+ }
49
+ }
50
+
51
+ export function assembleBoardMetrics(inputs: MetricsInputs): BoardMetrics {
52
+ const { doneColumnIds, inProgressColumnIds } = classifyColumnRoles(inputs.columnCounts)
53
+ const done = new Set(doneColumnIds)
54
+ const inProgress = new Set(inProgressColumnIds)
55
+
56
+ const tasksByColumn = inputs.columnCounts.map((column) => ({
57
+ column_name: column.name,
58
+ count: column.count,
59
+ }))
60
+ const completedTasks = inputs.columnCounts
61
+ .filter((column) => done.has(column.id))
62
+ .reduce((sum, column) => sum + column.count, 0)
63
+ const inProgressCount = inputs.columnCounts
64
+ .filter((column) => inProgress.has(column.id))
65
+ .reduce((sum, column) => sum + column.count, 0)
66
+
67
+ const tasksByPriority = [...inputs.priorityCounts].sort(
68
+ (a, b) => (PRIORITY_RANK[a.priority] ?? 99) - (PRIORITY_RANK[b.priority] ?? 99),
69
+ )
70
+
71
+ const completionPercent =
72
+ inputs.totalTasks > 0 ? Math.round((completedTasks / inputs.totalTasks) * 100) : 0
73
+
74
+ return {
75
+ tasksByColumn,
76
+ tasksByPriority,
77
+ totalTasks: inputs.totalTasks,
78
+ completedTasks,
79
+ avgCompletionHours: inputs.avgCompletionHours,
80
+ recentActivity: inputs.recentActivity,
81
+ tasksCreatedThisWeek: inputs.tasksCreatedThisWeek,
82
+ inProgressCount,
83
+ completionPercent,
84
+ assignees: inputs.assignees,
85
+ projects: inputs.projects,
86
+ }
87
+ }
package/src/metrics.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Database } from 'bun:sqlite'
2
2
  import type { ActivityEntry, BoardMetrics } from './types'
3
- import { selectDoneColumnIds, selectInProgressColumnIds } from './column-roles'
3
+ import { assembleBoardMetrics, classifyColumnRoles } from './metrics-spec'
4
4
 
5
5
  function getDistinctTaskFieldValues(db: Database, field: 'assignee' | 'project'): string[] {
6
6
  return (
@@ -23,52 +23,34 @@ export function getDiscoveredProjects(db: Database): string[] {
23
23
  }
24
24
 
25
25
  export function getBoardMetrics(db: Database): BoardMetrics {
26
- const columnCounts = db
27
- .query(
28
- `SELECT c.id as id, c.name as column_name, c.position as position, COUNT(t.id) as count
29
- FROM columns c LEFT JOIN tasks t ON t.column_id = c.id
30
- GROUP BY c.id ORDER BY c.position`,
31
- )
32
- .all() as { id: string; column_name: string; position: number; count: number }[]
33
-
34
- const tasksByColumn = columnCounts.map((row) => ({
35
- column_name: row.column_name,
36
- count: row.count,
37
- }))
38
-
39
- // Classify columns by role (handles custom names / KANBAN_DEFAULT_COLUMNS)
40
- // instead of matching the literal strings 'done' / 'in-progress'.
41
- const columns = columnCounts.map((row) => ({
42
- id: row.id,
43
- name: row.column_name,
44
- position: row.position,
45
- }))
46
- const doneColumnIds = new Set(selectDoneColumnIds(columns))
47
- const inProgressColumnIds = new Set(selectInProgressColumnIds(columns))
48
-
49
- const tasksByPriority = db
50
- .query(
51
- `SELECT priority, COUNT(*) as count FROM tasks
52
- GROUP BY priority ORDER BY CASE priority
53
- WHEN 'urgent' THEN 0 WHEN 'high' THEN 1
54
- WHEN 'medium' THEN 2 WHEN 'low' THEN 3 END`,
55
- )
26
+ const columnCounts = (
27
+ db
28
+ .query(
29
+ `SELECT c.id as id, c.name as name, c.position as position, COUNT(t.id) as count
30
+ FROM columns c LEFT JOIN tasks t ON t.column_id = c.id
31
+ GROUP BY c.id ORDER BY c.position`,
32
+ )
33
+ .all() as { id: string; name: string; position: number; count: number }[]
34
+ ).map((row) => ({ id: row.id, name: row.name, position: row.position, count: row.count }))
35
+
36
+ // Done/in-progress classification and all derived fields live in metrics-spec;
37
+ // here we only gather the raw aggregates this backend's SQL produces. The done
38
+ // ids are needed up front to scope the average-completion query.
39
+ const { doneColumnIds } = classifyColumnRoles(columnCounts)
40
+
41
+ const priorityCounts = db
42
+ .query(`SELECT priority, COUNT(*) as count FROM tasks GROUP BY priority`)
56
43
  .all() as { priority: string; count: number }[]
57
44
 
58
45
  const totalTasks = getCount(db, 'SELECT COUNT(*) as count FROM tasks')
59
46
 
60
- const completedTasks = columnCounts
61
- .filter((row) => doneColumnIds.has(row.id))
62
- .reduce((sum, row) => sum + row.count, 0)
63
-
64
47
  // Average completion time = first time a task was tracked (creation) until the
65
48
  // first time it entered a Done column. Measuring the Done *entry* (not exit)
66
49
  // means tasks that reach Done and stay there are counted; MIN() over Done rows
67
- // handles tasks that re-enter Done. Kept in lockstep with the Postgres copy in
68
- // postgres-local.ts:getMetrics update both together.
69
- const donePlaceholders = [...doneColumnIds].map(() => '?').join(', ')
70
- const avgResult = (
71
- doneColumnIds.size === 0
50
+ // handles tasks that re-enter Done.
51
+ const donePlaceholders = doneColumnIds.map(() => '?').join(', ')
52
+ const avgResult =
53
+ doneColumnIds.length === 0
72
54
  ? { avg_hours: null }
73
55
  : (db
74
56
  .query(
@@ -87,7 +69,6 @@ export function getBoardMetrics(db: Database): BoardMetrics {
87
69
  ) first_enter ON first_enter.task_id = done_enter.task_id`,
88
70
  )
89
71
  .get(...doneColumnIds) as { avg_hours: number | null })
90
- ) as { avg_hours: number | null }
91
72
 
92
73
  const recentActivity = db
93
74
  .query('SELECT * FROM activity_log ORDER BY timestamp DESC, rowid DESC LIMIT 20')
@@ -98,26 +79,14 @@ export function getBoardMetrics(db: Database): BoardMetrics {
98
79
  "SELECT COUNT(*) as count FROM tasks WHERE created_at >= datetime('now', '-7 days')",
99
80
  )
100
81
 
101
- const inProgressCount = columnCounts
102
- .filter((row) => inProgressColumnIds.has(row.id))
103
- .reduce((sum, row) => sum + row.count, 0)
104
-
105
- const completionPercent = totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0
106
-
107
- const assignees = getDiscoveredAssignees(db)
108
- const projects = getDiscoveredProjects(db)
109
-
110
- return {
111
- tasksByColumn,
112
- tasksByPriority,
82
+ return assembleBoardMetrics({
83
+ columnCounts,
84
+ priorityCounts,
113
85
  totalTasks,
114
- completedTasks,
86
+ tasksCreatedThisWeek,
115
87
  avgCompletionHours: avgResult.avg_hours,
116
88
  recentActivity,
117
- tasksCreatedThisWeek,
118
- inProgressCount,
119
- completionPercent,
120
- assignees,
121
- projects,
122
- }
89
+ assignees: getDiscoveredAssignees(db),
90
+ projects: getDiscoveredProjects(db),
91
+ })
123
92
  }
@@ -25,6 +25,18 @@ export const LOCAL_CAPABILITIES: ProviderCapabilities = capabilities({
25
25
  configEdit: true,
26
26
  })
27
27
 
28
+ // Postgres-local shares most local capabilities, but board administration
29
+ // (column CRUD, bulk ops) and config editing have no Postgres provider path —
30
+ // the CLI implements column/bulk against a raw SQLite Database and blocks them
31
+ // under KANBAN_STORAGE=postgres. Advertise that honestly so clients don't offer
32
+ // operations the provider can't perform.
33
+ export const POSTGRES_LOCAL_CAPABILITIES: ProviderCapabilities = {
34
+ ...LOCAL_CAPABILITIES,
35
+ columnCrud: false,
36
+ bulk: false,
37
+ configEdit: false,
38
+ }
39
+
28
40
  export const LINEAR_CAPABILITIES: ProviderCapabilities = capabilities()
29
41
 
30
42
  export const JIRA_CAPABILITIES: ProviderCapabilities = capabilities()
@@ -47,6 +47,7 @@ import type {
47
47
  UpdateTaskInput,
48
48
  } from './types'
49
49
  import { DEFAULT_POLLING_SYNC_INTERVAL_MS } from '../sync-config'
50
+ import { warnOnce } from './warn-once'
50
51
 
51
52
  export const FULL_RECONCILE_INTERVAL_MS = 5 * 60_000
52
53
 
@@ -856,7 +857,8 @@ export class JiraProviderCore implements KanbanProvider {
856
857
  // SQLite calls it directly via the default handleWebhook above.
857
858
  protected async handleWebhookCore(payload: WebhookRequest): Promise<WebhookResult> {
858
859
  if (!process.env['JIRA_WEBHOOK_SECRET']) {
859
- console.warn(
860
+ warnOnce(
861
+ 'jira-webhook-open-dev-mode',
860
862
  '[jira] JIRA_WEBHOOK_SECRET is not set — accepting webhook without signature verification (open dev mode)',
861
863
  )
862
864
  }
@@ -33,6 +33,7 @@ export interface LinearIssue {
33
33
  state: { id: string; name: string; position: number }
34
34
  labels?: string[]
35
35
  commentCount?: number
36
+ teamId?: string | null
36
37
  }
37
38
 
38
39
  export interface LinearComment {
@@ -65,6 +66,7 @@ interface LinearIssueNode {
65
66
  nodes: Array<{ id: string }>
66
67
  pageInfo?: { hasNextPage: boolean; endCursor: string | null }
67
68
  } | null
69
+ team?: { id: string } | null
68
70
  }
69
71
 
70
72
  interface LinearCommentNode {
@@ -86,6 +88,7 @@ function toLinearIssue(node: LinearIssueNode): LinearIssue {
86
88
  : null,
87
89
  labels: node.labels?.nodes.map((label) => label.name) ?? [],
88
90
  commentCount: node.comments?.nodes?.length ?? undefined,
91
+ teamId: node.team?.id ?? null,
89
92
  }
90
93
  }
91
94
 
@@ -97,6 +100,30 @@ const COMMENT_FIELDS = `
97
100
  user { id name displayName }
98
101
  `
99
102
 
103
+ // Shared selection set for an issue node, used by listIssues, createIssue, and
104
+ // getIssue so a single-issue hydrate returns exactly the same shape the bulk
105
+ // sync caches. The inline comments page is id-only and capped; callers that
106
+ // need an accurate count follow up via countIssueComments when hasNextPage.
107
+ const ISSUE_NODE_FIELDS = `
108
+ id
109
+ identifier
110
+ title
111
+ description
112
+ priority
113
+ url
114
+ createdAt
115
+ updatedAt
116
+ assignee { id name displayName }
117
+ project { id name url state }
118
+ state { id name position }
119
+ labels { nodes { id name } }
120
+ comments(first: 250) {
121
+ nodes { id }
122
+ pageInfo { hasNextPage endCursor }
123
+ }
124
+ team { id }
125
+ `
126
+
100
127
  export class LinearClient {
101
128
  private readonly endpoint = 'https://api.linear.app/graphql'
102
129
 
@@ -183,58 +210,80 @@ export class LinearClient {
183
210
  }
184
211
 
185
212
  async listUsers(): Promise<Array<{ id: string; name: string; active?: boolean }>> {
186
- const data = await this.query<{
187
- users: {
188
- nodes: Array<{
189
- id: string
190
- name?: string | null
191
- displayName?: string | null
192
- active?: boolean | null
193
- }>
194
- }
195
- }>(
196
- `
197
- query Users {
198
- users {
199
- nodes {
200
- id
201
- name
202
- displayName
203
- active
213
+ type UserNode = {
214
+ id: string
215
+ name?: string | null
216
+ displayName?: string | null
217
+ active?: boolean | null
218
+ }
219
+ let after: string | null = null
220
+ const users: Array<{ id: string; name: string; active?: boolean }> = []
221
+
222
+ do {
223
+ const data: { users: { nodes: UserNode[]; pageInfo: PageInfo } } = await this.query(
224
+ `
225
+ query Users($after: String) {
226
+ users(first: 100, after: $after) {
227
+ nodes {
228
+ id
229
+ name
230
+ displayName
231
+ active
232
+ }
233
+ pageInfo {
234
+ hasNextPage
235
+ endCursor
236
+ }
204
237
  }
205
238
  }
206
- }
207
- `,
208
- )
209
- return data.users.nodes.map((user) => ({
210
- id: user.id,
211
- name: user.displayName || user.name || user.id,
212
- active: user.active ?? true,
213
- }))
239
+ `,
240
+ { after },
241
+ )
242
+ for (const user of data.users.nodes) {
243
+ users.push({
244
+ id: user.id,
245
+ name: user.displayName || user.name || user.id,
246
+ active: user.active ?? true,
247
+ })
248
+ }
249
+ after = data.users.pageInfo.hasNextPage ? data.users.pageInfo.endCursor : null
250
+ } while (after)
251
+
252
+ return users
214
253
  }
215
254
 
216
255
  async listProjects(): Promise<
217
256
  Array<{ id: string; name: string; url?: string | null; state?: string | null }>
218
257
  > {
219
- const data = await this.query<{
220
- projects: {
221
- nodes: Array<{ id: string; name: string; url?: string | null; state?: string | null }>
222
- }
223
- }>(
224
- `
225
- query Projects {
226
- projects {
227
- nodes {
228
- id
229
- name
230
- url
231
- state
258
+ type ProjectNode = { id: string; name: string; url?: string | null; state?: string | null }
259
+ let after: string | null = null
260
+ const projects: ProjectNode[] = []
261
+
262
+ do {
263
+ const data: { projects: { nodes: ProjectNode[]; pageInfo: PageInfo } } = await this.query(
264
+ `
265
+ query Projects($after: String) {
266
+ projects(first: 100, after: $after) {
267
+ nodes {
268
+ id
269
+ name
270
+ url
271
+ state
272
+ }
273
+ pageInfo {
274
+ hasNextPage
275
+ endCursor
276
+ }
232
277
  }
233
278
  }
234
- }
235
- `,
236
- )
237
- return data.projects.nodes
279
+ `,
280
+ { after },
281
+ )
282
+ projects.push(...data.projects.nodes)
283
+ after = data.projects.pageInfo.hasNextPage ? data.projects.pageInfo.endCursor : null
284
+ } while (after)
285
+
286
+ return projects
238
287
  }
239
288
 
240
289
  async listIssueLabels(): Promise<LinearIssueLabel[]> {
@@ -293,39 +342,7 @@ export class LinearClient {
293
342
  updatedAt: { gte: $updatedAfter }
294
343
  }
295
344
  ) {
296
- nodes {
297
- id
298
- identifier
299
- title
300
- description
301
- priority
302
- url
303
- createdAt
304
- updatedAt
305
- assignee {
306
- id
307
- name
308
- displayName
309
- }
310
- project {
311
- id
312
- name
313
- url
314
- state
315
- }
316
- state {
317
- id
318
- name
319
- position
320
- }
321
- labels {
322
- nodes { id name }
323
- }
324
- comments(first: 250) {
325
- nodes { id }
326
- pageInfo { hasNextPage endCursor }
327
- }
328
- }
345
+ nodes { ${ISSUE_NODE_FIELDS} }
329
346
  pageInfo {
330
347
  hasNextPage
331
348
  endCursor
@@ -335,13 +352,90 @@ export class LinearClient {
335
352
  `,
336
353
  { teamId, after, updatedAfter: updatedAfter ?? '1970-01-01T00:00:00.000Z' },
337
354
  )
338
- issues.push(...data.issues.nodes.map(toLinearIssue))
355
+ const pageIssues = data.issues.nodes.map(toLinearIssue)
356
+ // Linear connections expose no totalCount, and the inline comments page is
357
+ // capped at 250. When an issue has more, the loaded length undercounts, so
358
+ // continue paging the (id-only) comment cursor to get an accurate count
359
+ // instead of advertising a partial page length as the total.
360
+ await Promise.all(
361
+ data.issues.nodes.map(async (node, index) => {
362
+ const info = node.comments?.pageInfo
363
+ if (info?.hasNextPage && info.endCursor) {
364
+ pageIssues[index]!.commentCount = await this.countIssueComments(
365
+ node.id,
366
+ info.endCursor,
367
+ node.comments?.nodes?.length ?? 0,
368
+ )
369
+ }
370
+ }),
371
+ )
372
+ issues.push(...pageIssues)
339
373
  after = data.issues.pageInfo.hasNextPage ? data.issues.pageInfo.endCursor : null
340
374
  } while (after)
341
375
 
342
376
  return issues
343
377
  }
344
378
 
379
+ // Continue paging an issue's comment cursor (id-only) to count comments beyond
380
+ // the inline first page. Only invoked when the first page reported hasNextPage.
381
+ private async countIssueComments(
382
+ issueId: string,
383
+ startCursor: string,
384
+ loaded: number,
385
+ ): Promise<number> {
386
+ let count = loaded
387
+ let after: string | null = startCursor
388
+
389
+ while (after) {
390
+ const data: {
391
+ issue: { comments: { nodes: Array<{ id: string }>; pageInfo: PageInfo } } | null
392
+ } = await this.query(
393
+ `
394
+ query IssueCommentCount($id: String!, $after: String) {
395
+ issue(id: $id) {
396
+ comments(first: 250, after: $after) {
397
+ nodes { id }
398
+ pageInfo { hasNextPage endCursor }
399
+ }
400
+ }
401
+ }
402
+ `,
403
+ { id: issueId, after },
404
+ )
405
+ const comments = data.issue?.comments
406
+ if (!comments) break
407
+ count += comments.nodes.length
408
+ after = comments.pageInfo.hasNextPage ? comments.pageInfo.endCursor : null
409
+ }
410
+
411
+ return count
412
+ }
413
+
414
+ // Fetch a single issue with the same selection set as the bulk sync so a
415
+ // read-after-write hydrate can refresh one cache row without a full team
416
+ // reconcile. Returns null when the issue no longer exists.
417
+ async getIssue(issueId: string): Promise<LinearIssue | null> {
418
+ const data = await this.query<{ issue: LinearIssueNode | null }>(
419
+ `
420
+ query IssueById($id: String!) {
421
+ issue(id: $id) { ${ISSUE_NODE_FIELDS} }
422
+ }
423
+ `,
424
+ { id: issueId },
425
+ )
426
+ if (!data.issue) return null
427
+ const issue = toLinearIssue(data.issue)
428
+ const info = data.issue.comments?.pageInfo
429
+ if (info?.hasNextPage && info.endCursor) {
430
+ issue.commentCount = await this.countIssueComments(
431
+ data.issue.id,
432
+ info.endCursor,
433
+ data.issue.comments?.nodes?.length ?? 0,
434
+ )
435
+ }
436
+ return issue
437
+ }
438
+
345
439
  async createIssue(input: {
346
440
  teamId: string
347
441
  stateId?: string
@@ -359,24 +453,7 @@ export class LinearClient {
359
453
  mutation CreateIssue($input: IssueCreateInput!) {
360
454
  issueCreate(input: $input) {
361
455
  success
362
- issue {
363
- id
364
- identifier
365
- title
366
- description
367
- priority
368
- url
369
- createdAt
370
- updatedAt
371
- assignee { id name displayName }
372
- project { id name url state }
373
- state { id name position }
374
- labels { nodes { id name } }
375
- comments(first: 250) {
376
- nodes { id }
377
- pageInfo { hasNextPage endCursor }
378
- }
379
- }
456
+ issue { ${ISSUE_NODE_FIELDS} }
380
457
  }
381
458
  }
382
459
  `,