@andypai/agent-kanban 0.4.0 → 0.5.1

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/labels.ts ADDED
@@ -0,0 +1,34 @@
1
+ export function normalizeLabels(input: unknown): string[] {
2
+ const labels: string[] = []
3
+ const seen = new Set<string>()
4
+
5
+ collectLabels(input, labels, seen)
6
+
7
+ return labels
8
+ }
9
+
10
+ export function parseStoredLabels(raw: unknown): string[] {
11
+ if (typeof raw !== 'string') return normalizeLabels(raw)
12
+
13
+ try {
14
+ return normalizeLabels(JSON.parse(raw))
15
+ } catch {
16
+ return normalizeLabels(raw)
17
+ }
18
+ }
19
+
20
+ function collectLabels(input: unknown, labels: string[], seen: Set<string>): void {
21
+ if (Array.isArray(input)) {
22
+ for (const item of input) collectLabels(item, labels, seen)
23
+ return
24
+ }
25
+
26
+ if (typeof input !== 'string') return
27
+
28
+ for (const part of input.split(',')) {
29
+ const label = part.trim()
30
+ if (!label || seen.has(label)) continue
31
+ seen.add(label)
32
+ labels.push(label)
33
+ }
34
+ }
@@ -1,8 +1,10 @@
1
1
  import type { Database } from 'bun:sqlite'
2
+ import { ErrorCode, KanbanError } from '../errors'
2
3
  import type { BoardView, ProviderTeamInfo, Task } from '../types'
3
4
 
4
5
  // Column ids are prefixed to avoid collisions across sources:
5
- // - board-sourced columns: 'board:<boardId>:<columnName>'
6
+ // - board-sourced columns: 'board:<boardId>:<columnName>' with an index suffix
7
+ // only when Jira returns duplicate board column names
6
8
  // - status-fallback columns: 'status:<statusId>'
7
9
  // The provider (T04) picks ONE source per sync, so mixed-source boards
8
10
  // do not occur in practice.
@@ -30,6 +32,67 @@ export interface JiraCacheConfig {
30
32
  issueTypes: Array<{ id: string; name: string }>
31
33
  }
32
34
 
35
+ export interface JiraBoardColumnInput {
36
+ name: string
37
+ statuses: Array<{ id: string }>
38
+ }
39
+
40
+ export function jiraBoardColumnRows(
41
+ boardId: number,
42
+ columns: JiraBoardColumnInput[],
43
+ ): Array<{
44
+ id: string
45
+ name: string
46
+ position: number
47
+ statusIds: string[]
48
+ source: 'board'
49
+ }> {
50
+ const seen = new Set<string>()
51
+ return columns.map((column, index) => {
52
+ const baseId = `board:${boardId}:${column.name}`
53
+ let id = baseId
54
+ if (seen.has(id)) {
55
+ id = `${baseId}:${index}`
56
+ let suffix = 2
57
+ while (seen.has(id)) {
58
+ id = `${baseId}:${index}:${suffix}`
59
+ suffix += 1
60
+ }
61
+ }
62
+ seen.add(id)
63
+ return {
64
+ id,
65
+ name: column.name,
66
+ position: index,
67
+ statusIds: column.statuses.map((status) => status.id),
68
+ source: 'board',
69
+ }
70
+ })
71
+ }
72
+
73
+ // Resolves a user-supplied column reference to a cached column id, trying
74
+ // (1) exact id, (2) case-insensitive name, (3) raw status id containment.
75
+ // A name that matches multiple columns (possible when Jira returns duplicate
76
+ // board column names) is rejected as ambiguous so the caller picks an id.
77
+ export function resolveJiraColumnId(columns: JiraColumnRow[], input: string): string {
78
+ const byId = columns.find((column) => column.id === input)
79
+ if (byId) return byId.id
80
+ const lower = input.toLowerCase()
81
+ const byName = columns.filter((column) => column.name.toLowerCase() === lower)
82
+ if (byName.length === 1) return byName[0]!.id
83
+ if (byName.length > 1) {
84
+ throw new KanbanError(
85
+ ErrorCode.COLUMN_NOT_FOUND,
86
+ `Jira column name '${input}' is ambiguous; use one of these column ids: ${byName
87
+ .map((column) => column.id)
88
+ .join(', ')}`,
89
+ )
90
+ }
91
+ const byStatus = columns.find((column) => decodeColumnStatusIds(column).includes(input))
92
+ if (byStatus) return byStatus.id
93
+ throw new KanbanError(ErrorCode.COLUMN_NOT_FOUND, `No Jira column matching '${input}'`)
94
+ }
95
+
33
96
  interface JiraIssueRow {
34
97
  id: string
35
98
  key: string
@@ -388,3 +388,7 @@ export class JiraClient {
388
388
  })
389
389
  }
390
390
  }
391
+
392
+ export function normalizeJiraLabels(labels: string[] | undefined): string[] {
393
+ return (labels ?? []).map((label) => label.trim()).filter(Boolean)
394
+ }
@@ -20,7 +20,7 @@ import {
20
20
  import { adfToPlainText, plainTextToAdf, type AdfDocument } from './jira-adf'
21
21
  import { JIRA_CAPABILITIES } from './capabilities'
22
22
  import { providerUpstreamError, unsupportedOperation } from './errors'
23
- import { JiraClient, type JiraComment, type JiraIssue } from './jira-client'
23
+ import { JiraClient, normalizeJiraLabels, type JiraComment, type JiraIssue } from './jira-client'
24
24
  import {
25
25
  adjustJiraIssueCommentCount,
26
26
  decodeColumnStatusIds,
@@ -32,6 +32,8 @@ import {
32
32
  getCachedTask,
33
33
  getCachedTasks,
34
34
  initJiraCacheSchema,
35
+ jiraBoardColumnRows,
36
+ resolveJiraColumnId,
35
37
  loadJiraSyncMeta,
36
38
  loadTeamInfo,
37
39
  pruneJiraIssuesMissingUpstream,
@@ -122,13 +124,7 @@ export class JiraProvider implements KanbanProvider {
122
124
  if (this.config.boardId !== undefined) {
123
125
  const boardCfg = await this.client.getBoardColumns(this.config.boardId)
124
126
  const boardId = this.config.boardId
125
- const rows = boardCfg.columnConfig.columns.map((col, i) => ({
126
- id: `board:${boardId}:${col.name}`,
127
- name: col.name,
128
- position: i,
129
- statusIds: col.statuses.map((s) => s.id),
130
- source: 'board' as const,
131
- }))
127
+ const rows = jiraBoardColumnRows(boardId, boardCfg.columnConfig.columns)
132
128
  replaceJiraColumns(this.db, rows)
133
129
  } else {
134
130
  const statusCats = await this.client.getProjectStatuses(project.key)
@@ -275,18 +271,7 @@ export class JiraProvider implements KanbanProvider {
275
271
  }
276
272
 
277
273
  private resolveColumnId(input: string): string {
278
- const columns = getCachedColumns(this.db)
279
- // Priority 1: exact id.
280
- const byId = columns.find((c) => c.id === input)
281
- if (byId) return byId.id
282
- // Priority 2: case-insensitive name.
283
- const lower = input.toLowerCase()
284
- const byName = columns.find((c) => c.name.toLowerCase() === lower)
285
- if (byName) return byName.id
286
- // Priority 3: status_ids containment (raw status id).
287
- const byStatus = columns.find((c) => decodeColumnStatusIds(c).includes(input))
288
- if (byStatus) return byStatus.id
289
- throw new KanbanError(ErrorCode.COLUMN_NOT_FOUND, `No Jira column matching '${input}'`)
274
+ return resolveJiraColumnId(getCachedColumns(this.db), input)
290
275
  }
291
276
 
292
277
  private async buildBoardConfig(): Promise<BoardConfig> {
@@ -488,6 +473,8 @@ export class JiraProvider implements KanbanProvider {
488
473
  accountId: this.resolveAssigneeAccountId(input.assignee),
489
474
  }
490
475
  }
476
+ const labels = normalizeJiraLabels(input.labels)
477
+ if (labels.length > 0) fields['labels'] = labels
491
478
  // Column at create-time is intentionally unsupported in Jira mode: new
492
479
  // issues land in the project workflow's default start state. Use
493
480
  // `moveTask` after create to change status.
@@ -43,6 +43,11 @@ export interface LinearComment {
43
43
  user?: { id: string; name?: string | null; displayName?: string | null } | null
44
44
  }
45
45
 
46
+ export interface LinearIssueLabel {
47
+ id: string
48
+ name: string
49
+ }
50
+
46
51
  interface LinearIssueNode {
47
52
  id: string
48
53
  identifier: string
@@ -232,6 +237,40 @@ export class LinearClient {
232
237
  return data.projects.nodes
233
238
  }
234
239
 
240
+ async listIssueLabels(): Promise<LinearIssueLabel[]> {
241
+ let after: string | null = null
242
+ const labels: LinearIssueLabel[] = []
243
+
244
+ do {
245
+ const data: {
246
+ issueLabels: {
247
+ nodes: LinearIssueLabel[]
248
+ pageInfo: PageInfo
249
+ }
250
+ } = await this.query(
251
+ `
252
+ query IssueLabels($after: String) {
253
+ issueLabels(first: 100, after: $after) {
254
+ nodes {
255
+ id
256
+ name
257
+ }
258
+ pageInfo {
259
+ hasNextPage
260
+ endCursor
261
+ }
262
+ }
263
+ }
264
+ `,
265
+ { after },
266
+ )
267
+ labels.push(...data.issueLabels.nodes)
268
+ after = data.issueLabels.pageInfo.hasNextPage ? data.issueLabels.pageInfo.endCursor : null
269
+ } while (after)
270
+
271
+ return labels
272
+ }
273
+
235
274
  async listIssues(teamId: string, updatedAfter?: string): Promise<LinearIssue[]> {
236
275
  let after: string | null = null
237
276
  const issues: LinearIssue[] = []
@@ -311,6 +350,7 @@ export class LinearClient {
311
350
  priority?: number
312
351
  assigneeId?: string
313
352
  projectId?: string
353
+ labelIds?: string[]
314
354
  }): Promise<{ success: boolean; issue: LinearIssue | null }> {
315
355
  const data = await this.query<{
316
356
  issueCreate: { success: boolean; issue: LinearIssueNode | null }
@@ -349,6 +389,7 @@ export class LinearClient {
349
389
  priority: input.priority,
350
390
  assigneeId: input.assigneeId,
351
391
  projectId: input.projectId,
392
+ labelIds: input.labelIds,
352
393
  },
353
394
  },
354
395
  )
@@ -567,3 +608,57 @@ export class LinearClient {
567
608
  }
568
609
  }
569
610
  }
611
+
612
+ export async function resolveLabelIdsForCreate(
613
+ client: LinearClient,
614
+ inputLabels: string[] | undefined,
615
+ ): Promise<string[] | undefined> {
616
+ if (!inputLabels?.some((label) => label.trim())) return undefined
617
+ return resolveLinearLabelIds(inputLabels, await client.listIssueLabels())
618
+ }
619
+
620
+ export function resolveLinearLabelIds(
621
+ inputLabels: string[] | undefined,
622
+ availableLabels: LinearIssueLabel[],
623
+ ): string[] | undefined {
624
+ const requested = dedupeLabelQueries(inputLabels)
625
+ if (requested.length === 0) return undefined
626
+
627
+ const byName = new Map(
628
+ availableLabels.map((label) => [label.name.trim().toLowerCase(), label.id]),
629
+ )
630
+ const knownIds = new Set(availableLabels.map((label) => label.id))
631
+ const ids: string[] = []
632
+ const missing: string[] = []
633
+
634
+ for (const label of requested) {
635
+ const id = byName.get(label.toLowerCase()) ?? (knownIds.has(label) ? label : undefined)
636
+ if (!id) {
637
+ missing.push(label)
638
+ continue
639
+ }
640
+ if (!ids.includes(id)) ids.push(id)
641
+ }
642
+
643
+ if (missing.length > 0) {
644
+ providerUpstreamError(
645
+ `Linear label${missing.length === 1 ? '' : 's'} not found: ${missing.join(', ')}`,
646
+ )
647
+ }
648
+
649
+ return ids.length > 0 ? ids : undefined
650
+ }
651
+
652
+ function dedupeLabelQueries(labels: string[] | undefined): string[] {
653
+ const out: string[] = []
654
+ const seen = new Set<string>()
655
+ for (const label of labels ?? []) {
656
+ const trimmed = label.trim()
657
+ if (!trimmed) continue
658
+ const key = trimmed.toLowerCase()
659
+ if (seen.has(key)) continue
660
+ seen.add(key)
661
+ out.push(trimmed)
662
+ }
663
+ return out
664
+ }
@@ -31,7 +31,7 @@ import {
31
31
  upsertUsers,
32
32
  type LinearActivityRow,
33
33
  } from './linear-cache'
34
- import { LinearClient, type LinearComment } from './linear-client'
34
+ import { LinearClient, resolveLabelIdsForCreate, type LinearComment } from './linear-client'
35
35
  import { unsupportedOperation } from './errors'
36
36
  import type {
37
37
  CreateTaskInput,
@@ -349,6 +349,7 @@ export class LinearProvider implements KanbanProvider {
349
349
  async createTask(input: CreateTaskInput) {
350
350
  await this.sync()
351
351
  const state = input.column ? this.resolveState(input.column) : undefined
352
+ const labelIds = await resolveLabelIdsForCreate(this.client, input.labels)
352
353
  const result = await this.client.createIssue({
353
354
  teamId: this.resolvedTeamId(),
354
355
  stateId: state?.id,
@@ -357,6 +358,7 @@ export class LinearProvider implements KanbanProvider {
357
358
  priority: toLinearPriority(input.priority),
358
359
  assigneeId: this.resolveAssigneeId(input.assignee),
359
360
  projectId: this.resolveProjectId(input.project),
361
+ labelIds,
360
362
  })
361
363
  if (!result.success || !result.issue) {
362
364
  throw new KanbanError(ErrorCode.PROVIDER_UPSTREAM_ERROR, 'Linear issue creation failed')
@@ -56,13 +56,14 @@ export class LocalProvider implements KanbanProvider {
56
56
  private enrichTask(task: Task, commentCount?: number): Task {
57
57
  const revision = task.revision ?? 0
58
58
  const assignees = task.assignee ? [task.assignee] : []
59
+ const labels = Array.isArray(task.labels) ? task.labels : []
59
60
  return {
60
61
  ...task,
61
62
  providerId: task.id,
62
63
  externalRef: task.id,
63
64
  url: null,
64
65
  assignees,
65
- labels: [],
66
+ labels,
66
67
  comment_count: commentCount ?? countComments(this.db, task.id),
67
68
  version: String(revision),
68
69
  source_updated_at: null,
@@ -14,9 +14,15 @@ import type {
14
14
  TaskComment,
15
15
  } from '../types'
16
16
  import { JIRA_CAPABILITIES } from './capabilities'
17
- import { decodeColumnStatusIds, type JiraActivityRow, type JiraColumnRow } from './jira-cache'
17
+ import {
18
+ decodeColumnStatusIds,
19
+ jiraBoardColumnRows,
20
+ resolveJiraColumnId,
21
+ type JiraActivityRow,
22
+ type JiraColumnRow,
23
+ } from './jira-cache'
18
24
  import { adfToPlainText, plainTextToAdf, type AdfDocument } from './jira-adf'
19
- import { JiraClient, type JiraComment, type JiraIssue } from './jira-client'
25
+ import { JiraClient, normalizeJiraLabels, type JiraComment, type JiraIssue } from './jira-client'
20
26
  import type { JiraProviderConfig } from './jira'
21
27
  import { providerUpstreamError, unsupportedOperation } from './errors'
22
28
  import type {
@@ -605,15 +611,7 @@ export class PostgresJiraProvider implements KanbanProvider {
605
611
  if (this.config.boardId !== undefined) {
606
612
  const boardCfg = await this.client.getBoardColumns(this.config.boardId)
607
613
  const boardId = this.config.boardId
608
- await this.replaceColumns(
609
- boardCfg.columnConfig.columns.map((column, index) => ({
610
- id: `board:${boardId}:${column.name}`,
611
- name: column.name,
612
- position: index,
613
- statusIds: column.statuses.map((status) => status.id),
614
- source: 'board' as const,
615
- })),
616
- )
614
+ await this.replaceColumns(jiraBoardColumnRows(boardId, boardCfg.columnConfig.columns))
617
615
  } else {
618
616
  const statusCats = await this.client.getProjectStatuses(project.key)
619
617
  const seen = new Set<string>()
@@ -741,15 +739,7 @@ export class PostgresJiraProvider implements KanbanProvider {
741
739
  }
742
740
 
743
741
  private async resolveColumnId(input: string): Promise<string> {
744
- const columns = await this.getColumns()
745
- const byId = columns.find((column) => column.id === input)
746
- if (byId) return byId.id
747
- const lower = input.toLowerCase()
748
- const byName = columns.find((column) => column.name.toLowerCase() === lower)
749
- if (byName) return byName.id
750
- const byStatus = columns.find((column) => decodeColumnStatusIds(column).includes(input))
751
- if (byStatus) return byStatus.id
752
- throw new KanbanError(ErrorCode.COLUMN_NOT_FOUND, `No Jira column matching '${input}'`)
742
+ return resolveJiraColumnId(await this.getColumns(), input)
753
743
  }
754
744
 
755
745
  private async buildBoardConfig(): Promise<BoardConfig> {
@@ -948,6 +938,8 @@ export class PostgresJiraProvider implements KanbanProvider {
948
938
  if (input.assignee) {
949
939
  fields['assignee'] = { accountId: await this.resolveAssigneeAccountId(input.assignee) }
950
940
  }
941
+ const labels = normalizeJiraLabels(input.labels)
942
+ if (labels.length > 0) fields['labels'] = labels
951
943
  const created = await this.client.createIssue({ fields })
952
944
  await this.sync(true)
953
945
  const fresh = await this.getCachedTask(created.key)
@@ -22,7 +22,7 @@ import {
22
22
  } from '../webhook-events'
23
23
  import { LINEAR_CAPABILITIES } from './capabilities'
24
24
  import { unsupportedOperation } from './errors'
25
- import { LinearClient, type LinearComment } from './linear-client'
25
+ import { LinearClient, resolveLabelIdsForCreate, type LinearComment } from './linear-client'
26
26
  import type {
27
27
  CreateTaskInput,
28
28
  KanbanProvider,
@@ -822,6 +822,7 @@ export class PostgresLinearProvider implements KanbanProvider {
822
822
  async createTask(input: CreateTaskInput): Promise<Task> {
823
823
  await this.sync()
824
824
  const state = input.column ? await this.resolveState(input.column) : undefined
825
+ const labelIds = await resolveLabelIdsForCreate(this.client, input.labels)
825
826
  const result = await this.client.createIssue({
826
827
  teamId: await this.resolvedTeamId(),
827
828
  stateId: state?.id,
@@ -830,6 +831,7 @@ export class PostgresLinearProvider implements KanbanProvider {
830
831
  priority: toLinearPriority(input.priority),
831
832
  assigneeId: await this.resolveAssigneeId(input.assignee),
832
833
  projectId: await this.resolveProjectId(input.project),
834
+ labelIds,
833
835
  })
834
836
  if (!result.success || !result.issue) {
835
837
  throw new KanbanError(ErrorCode.PROVIDER_UPSTREAM_ERROR, 'Linear issue creation failed')
@@ -24,6 +24,7 @@ import type {
24
24
  UpdateTaskInput,
25
25
  } from './types'
26
26
  import type { LocalTrackerConfig } from '../tracker-config'
27
+ import { normalizeLabels, parseStoredLabels } from '../labels'
27
28
 
28
29
  const DEFAULT_COLUMNS = [
29
30
  { name: 'recurring', position: 0 },
@@ -43,6 +44,7 @@ interface TaskRow {
43
44
  priority: Priority
44
45
  assignee: string
45
46
  project: string
47
+ labels: string
46
48
  metadata: string
47
49
  revision: number
48
50
  created_at: string
@@ -126,6 +128,7 @@ export class PostgresLocalProvider implements KanbanProvider {
126
128
  priority TEXT NOT NULL DEFAULT 'medium',
127
129
  assignee TEXT NOT NULL DEFAULT '',
128
130
  project TEXT NOT NULL DEFAULT '',
131
+ labels TEXT NOT NULL DEFAULT '[]',
129
132
  metadata TEXT NOT NULL DEFAULT '{}',
130
133
  revision INTEGER NOT NULL DEFAULT 0,
131
134
  created_at TEXT NOT NULL,
@@ -162,6 +165,7 @@ export class PostgresLocalProvider implements KanbanProvider {
162
165
  exited_at TEXT
163
166
  )
164
167
  `
168
+ await this.sql`ALTER TABLE tasks ADD COLUMN IF NOT EXISTS labels TEXT NOT NULL DEFAULT '[]'`
165
169
  await this.sql`CREATE INDEX IF NOT EXISTS idx_tasks_column_id ON tasks(column_id)`
166
170
  await this.sql`CREATE INDEX IF NOT EXISTS idx_tasks_priority ON tasks(priority)`
167
171
  await this.sql`CREATE INDEX IF NOT EXISTS idx_tasks_assignee ON tasks(assignee)`
@@ -194,7 +198,7 @@ export class PostgresLocalProvider implements KanbanProvider {
194
198
  externalRef: row.id,
195
199
  url: null,
196
200
  assignees: row.assignee ? [row.assignee] : [],
197
- labels: [],
201
+ labels: parseStoredLabels(row.labels),
198
202
  comment_count: commentCount,
199
203
  version: String(row.revision ?? 0),
200
204
  source_updated_at: null,
@@ -352,6 +356,7 @@ export class PostgresLocalProvider implements KanbanProvider {
352
356
  const priority = input.priority ?? 'medium'
353
357
  assertPriority(priority)
354
358
  const metadata = parseMetadata(input.metadata)
359
+ const labels = normalizeLabels(input.labels)
355
360
  const column = input.column
356
361
  ? await this.resolveColumn(input.column)
357
362
  : await this.resolveDefaultTaskColumn()
@@ -362,12 +367,12 @@ export class PostgresLocalProvider implements KanbanProvider {
362
367
  const timestamp = nowIso()
363
368
  await this.sql`
364
369
  INSERT INTO tasks (
365
- id, title, description, column_id, position, priority, assignee, project, metadata,
370
+ id, title, description, column_id, position, priority, assignee, project, labels, metadata,
366
371
  revision, created_at, updated_at
367
372
  )
368
373
  VALUES (
369
374
  ${id}, ${input.title}, ${input.description ?? ''}, ${column.id}, ${Number(positionRow?.next ?? 0)},
370
- ${priority}, ${input.assignee ?? ''}, ${input.project ?? ''}, ${metadata},
375
+ ${priority}, ${input.assignee ?? ''}, ${input.project ?? ''}, ${JSON.stringify(labels)}, ${metadata},
371
376
  0, ${timestamp}, ${timestamp}
372
377
  )
373
378
  `
@@ -29,6 +29,7 @@ export interface CreateTaskInput {
29
29
  priority?: Priority
30
30
  assignee?: string
31
31
  project?: string
32
+ labels?: string[]
32
33
  metadata?: string
33
34
  }
34
35