@andypai/agent-kanban 0.6.3 → 0.6.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +11 -3
- package/src/__tests__/index.test.ts +27 -1
- package/src/__tests__/jira-cache.test.ts +68 -0
- package/src/__tests__/jira-provider-read.test.ts +130 -0
- package/src/__tests__/jira-wiring.test.ts +41 -0
- package/src/__tests__/linear-provider-comment.test.ts +81 -0
- package/src/__tests__/linear-provider-sync.test.ts +107 -0
- package/src/__tests__/linear-state-resolution.test.ts +60 -0
- package/src/__tests__/local-provider-core.test.ts +245 -0
- package/src/__tests__/postgres-jira-provider.test.ts +55 -0
- package/src/__tests__/postgres-linear-provider.test.ts +90 -0
- package/src/__tests__/postgres-local-provider.test.ts +26 -0
- package/src/__tests__/provider-sync-core.test.ts +150 -0
- package/src/__tests__/provider-team-info.test.ts +37 -0
- package/src/__tests__/ui-board-slice.test.ts +14 -0
- package/src/__tests__/use-cases.test.ts +21 -42
- package/src/api.ts +78 -132
- package/src/column-roles.ts +3 -2
- package/src/index.ts +135 -92
- package/src/mcp/core.ts +7 -8
- package/src/provider-runtime.ts +12 -56
- package/src/providers/cache-task-mappers.ts +123 -0
- package/src/providers/factory.ts +103 -0
- package/src/providers/index.ts +4 -25
- package/src/providers/jira-cache.ts +41 -99
- package/src/providers/jira-core.ts +81 -81
- package/src/providers/linear-cache.ts +42 -69
- package/src/providers/linear-core.ts +44 -56
- package/src/providers/local-core.ts +226 -0
- package/src/providers/local.ts +6 -182
- package/src/providers/postgres-batch.ts +10 -0
- package/src/providers/postgres-jira-cache.ts +255 -213
- package/src/providers/postgres-jira.ts +2 -20
- package/src/providers/postgres-linear-cache.ts +279 -183
- package/src/providers/postgres-linear.ts +2 -20
- package/src/providers/postgres-local.ts +22 -47
- package/src/providers/sqlite-local-store.ts +121 -0
- package/src/providers/sync-core.ts +91 -0
- package/src/providers/team-info.ts +24 -0
- package/src/providers/warn-once.ts +15 -0
- package/src/use-cases.ts +7 -131
- package/src/webhook-events.ts +29 -1
- package/ui/dist/assets/index-DcZH7fI3.js +40 -0
- package/ui/dist/index.html +1 -1
- package/ui/dist/assets/index-Cigv8a9S.js +0 -40
|
@@ -1,11 +1,10 @@
|
|
|
1
|
-
import type { Sql
|
|
1
|
+
import type { Sql } from 'postgres'
|
|
2
2
|
|
|
3
3
|
import { ErrorCode, KanbanError } from '../errors'
|
|
4
4
|
import { generateId } from '../id'
|
|
5
5
|
import { assembleBoardMetrics, classifyColumnRoles } from '../metrics-spec'
|
|
6
6
|
import type {
|
|
7
7
|
ActivityEntry,
|
|
8
|
-
BoardBootstrap,
|
|
9
8
|
BoardConfig,
|
|
10
9
|
BoardMetrics,
|
|
11
10
|
BoardView,
|
|
@@ -17,20 +16,11 @@ import type {
|
|
|
17
16
|
} from '../types'
|
|
18
17
|
import { POSTGRES_LOCAL_CAPABILITIES } from './capabilities'
|
|
19
18
|
import { unsupportedOperation } from './errors'
|
|
20
|
-
import type
|
|
21
|
-
|
|
22
|
-
KanbanProvider,
|
|
23
|
-
ProviderContext,
|
|
24
|
-
ProviderSyncStatus,
|
|
25
|
-
TaskListFilters,
|
|
26
|
-
UpdateTaskInput,
|
|
27
|
-
} from './types'
|
|
19
|
+
import { LocalProviderCore, type LocalStorePort } from './local-core'
|
|
20
|
+
import type { CreateTaskInput, TaskListFilters, UpdateTaskInput } from './types'
|
|
28
21
|
import type { LocalTrackerConfig } from '../tracker-config'
|
|
29
22
|
import { normalizeLabels, parseStoredLabels } from '../labels'
|
|
30
|
-
|
|
31
|
-
// Accepts either the root connection or a scoped transaction handle so the
|
|
32
|
-
// mutation helpers can run inside or outside a `sql.begin(...)` block.
|
|
33
|
-
type Exec = Sql | TransactionSql
|
|
23
|
+
import type { Exec } from './postgres-batch'
|
|
34
24
|
|
|
35
25
|
const DEFAULT_COLUMNS = [
|
|
36
26
|
{ name: 'recurring', position: 0 },
|
|
@@ -98,8 +88,8 @@ function parseMetadata(metadata: string | undefined): string {
|
|
|
98
88
|
}
|
|
99
89
|
}
|
|
100
90
|
|
|
101
|
-
|
|
102
|
-
readonly
|
|
91
|
+
class PostgresLocalStore implements LocalStorePort {
|
|
92
|
+
readonly capabilities = POSTGRES_LOCAL_CAPABILITIES
|
|
103
93
|
private readonly ready: Promise<void>
|
|
104
94
|
|
|
105
95
|
constructor(
|
|
@@ -294,25 +284,6 @@ export class PostgresLocalProvider implements KanbanProvider {
|
|
|
294
284
|
`
|
|
295
285
|
}
|
|
296
286
|
|
|
297
|
-
async getContext(): Promise<ProviderContext> {
|
|
298
|
-
await this.ready
|
|
299
|
-
return { provider: this.type, capabilities: POSTGRES_LOCAL_CAPABILITIES, team: null }
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
async getBootstrap(): Promise<BoardBootstrap> {
|
|
303
|
-
await this.ready
|
|
304
|
-
const metrics = await this.getMetrics()
|
|
305
|
-
return {
|
|
306
|
-
provider: this.type,
|
|
307
|
-
capabilities: POSTGRES_LOCAL_CAPABILITIES,
|
|
308
|
-
board: await this.getBoard(),
|
|
309
|
-
config: await this.getConfig(),
|
|
310
|
-
metrics,
|
|
311
|
-
activity: await this.getActivity(50),
|
|
312
|
-
team: null,
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
|
-
|
|
316
287
|
async getBoard(): Promise<BoardView> {
|
|
317
288
|
await this.ready
|
|
318
289
|
const columns = await this.listColumns()
|
|
@@ -386,6 +357,11 @@ export class PostgresLocalProvider implements KanbanProvider {
|
|
|
386
357
|
return this.enrichTask(row, counts.get(row.id) ?? 0)
|
|
387
358
|
}
|
|
388
359
|
|
|
360
|
+
async getTaskVersion(idOrRef: string): Promise<string> {
|
|
361
|
+
const row = await this.requireTask(idOrRef)
|
|
362
|
+
return String(row.revision ?? 0)
|
|
363
|
+
}
|
|
364
|
+
|
|
389
365
|
async createTask(input: CreateTaskInput): Promise<Task> {
|
|
390
366
|
await this.ready
|
|
391
367
|
const priority = input.priority ?? 'medium'
|
|
@@ -418,18 +394,12 @@ export class PostgresLocalProvider implements KanbanProvider {
|
|
|
418
394
|
return this.getTask(id)
|
|
419
395
|
}
|
|
420
396
|
|
|
421
|
-
async updateTask(
|
|
397
|
+
async updateTask(
|
|
398
|
+
idOrRef: string,
|
|
399
|
+
input: Omit<UpdateTaskInput, 'expectedVersion'>,
|
|
400
|
+
): Promise<Task> {
|
|
422
401
|
await this.ready
|
|
423
402
|
const current = await this.requireTask(idOrRef)
|
|
424
|
-
if (
|
|
425
|
-
input.expectedVersion !== undefined &&
|
|
426
|
-
String(current.revision ?? 0) !== input.expectedVersion
|
|
427
|
-
) {
|
|
428
|
-
throw new KanbanError(
|
|
429
|
-
ErrorCode.CONFLICT,
|
|
430
|
-
`Task ${idOrRef} was modified since you loaded it (expected version ${input.expectedVersion}, current ${current.revision ?? 0})`,
|
|
431
|
-
)
|
|
432
|
-
}
|
|
433
403
|
|
|
434
404
|
if (input.priority !== undefined) assertPriority(input.priority)
|
|
435
405
|
const metadata = input.metadata === undefined ? undefined : parseMetadata(input.metadata)
|
|
@@ -763,8 +733,13 @@ export class PostgresLocalProvider implements KanbanProvider {
|
|
|
763
733
|
// and fail loudly so the HTTP API and CLI agree.
|
|
764
734
|
unsupportedOperation('Editing board config is not supported with KANBAN_STORAGE=postgres')
|
|
765
735
|
}
|
|
736
|
+
}
|
|
766
737
|
|
|
767
|
-
|
|
768
|
-
|
|
738
|
+
export class PostgresLocalProvider extends LocalProviderCore {
|
|
739
|
+
constructor(
|
|
740
|
+
sql: Sql,
|
|
741
|
+
config: Pick<LocalTrackerConfig, 'defaultColumns' | 'defaultTaskColumn'> = {},
|
|
742
|
+
) {
|
|
743
|
+
super(new PostgresLocalStore(sql, config))
|
|
769
744
|
}
|
|
770
745
|
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type { Database } from 'bun:sqlite'
|
|
2
|
+
|
|
3
|
+
import { listActivity } from '../activity'
|
|
4
|
+
import { getConfigPath, loadConfig, saveConfig } from '../config'
|
|
5
|
+
import {
|
|
6
|
+
addComment,
|
|
7
|
+
countComments,
|
|
8
|
+
countCommentsByTask,
|
|
9
|
+
addTask,
|
|
10
|
+
deleteTask,
|
|
11
|
+
getBoardView,
|
|
12
|
+
getComment as getTaskComment,
|
|
13
|
+
getTask,
|
|
14
|
+
listComments as listTaskComments,
|
|
15
|
+
listColumns,
|
|
16
|
+
listTasks,
|
|
17
|
+
moveTask,
|
|
18
|
+
updateComment as updateTaskComment,
|
|
19
|
+
updateTask,
|
|
20
|
+
} from '../db'
|
|
21
|
+
import { getBoardMetrics, getDiscoveredAssignees, getDiscoveredProjects } from '../metrics'
|
|
22
|
+
import type { BoardConfig, BoardMetrics, TaskComment } from '../types'
|
|
23
|
+
import { LOCAL_CAPABILITIES } from './capabilities'
|
|
24
|
+
import type { CreateTaskInput, TaskListFilters, UpdateTaskInput } from './types'
|
|
25
|
+
import type { LocalStorePort } from './local-core'
|
|
26
|
+
|
|
27
|
+
export class SqliteLocalStore implements LocalStorePort {
|
|
28
|
+
readonly capabilities = LOCAL_CAPABILITIES
|
|
29
|
+
|
|
30
|
+
constructor(
|
|
31
|
+
private readonly db: Database,
|
|
32
|
+
private readonly dbPath: string,
|
|
33
|
+
) {}
|
|
34
|
+
|
|
35
|
+
getBoard() {
|
|
36
|
+
return getBoardView(this.db)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
listColumns() {
|
|
40
|
+
return listColumns(this.db)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
listTasks(filters: TaskListFilters = {}) {
|
|
44
|
+
return listTasks(this.db, filters)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
getTask(idOrRef: string) {
|
|
48
|
+
return getTask(this.db, idOrRef)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
getTaskVersion(idOrRef: string): string {
|
|
52
|
+
return String(getTask(this.db, idOrRef).revision ?? 0)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
createTask(input: CreateTaskInput) {
|
|
56
|
+
return addTask(this.db, input.title, input)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
updateTask(idOrRef: string, input: Omit<UpdateTaskInput, 'expectedVersion'>) {
|
|
60
|
+
return updateTask(this.db, idOrRef, input)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
moveTask(idOrRef: string, column: string) {
|
|
64
|
+
return moveTask(this.db, idOrRef, column)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
deleteTask(idOrRef: string) {
|
|
68
|
+
return deleteTask(this.db, idOrRef)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
listComments(idOrRef: string): TaskComment[] {
|
|
72
|
+
return listTaskComments(this.db, idOrRef)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
getComment(idOrRef: string, commentId: string): TaskComment {
|
|
76
|
+
return getTaskComment(this.db, idOrRef, commentId)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
comment(idOrRef: string, body: string): TaskComment {
|
|
80
|
+
return addComment(this.db, idOrRef, body)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
updateComment(idOrRef: string, commentId: string, body: string): TaskComment {
|
|
84
|
+
return updateTaskComment(this.db, idOrRef, commentId, body)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
getActivity(limit?: number, taskId?: string) {
|
|
88
|
+
return listActivity(this.db, { limit, taskId })
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
getMetrics() {
|
|
92
|
+
return getBoardMetrics(this.db)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
getConfig(context: { metrics?: BoardMetrics } = {}): BoardConfig {
|
|
96
|
+
const discoveredAssignees = context.metrics?.assignees ?? getDiscoveredAssignees(this.db)
|
|
97
|
+
const discoveredProjects = context.metrics?.projects ?? getDiscoveredProjects(this.db)
|
|
98
|
+
return {
|
|
99
|
+
...loadConfig(this.dbPath),
|
|
100
|
+
provider: 'local',
|
|
101
|
+
discoveredAssignees,
|
|
102
|
+
discoveredProjects,
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
patchConfig(input: Partial<BoardConfig>): BoardConfig {
|
|
107
|
+
const config = loadConfig(this.dbPath)
|
|
108
|
+
if (input.members) config.members = input.members
|
|
109
|
+
if (input.projects) config.projects = input.projects
|
|
110
|
+
saveConfig(getConfigPath(this.dbPath), config)
|
|
111
|
+
return this.getConfig()
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
countComments(taskId: string): number {
|
|
115
|
+
return countComments(this.db, taskId)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
countCommentsByTask(): Map<string, number> {
|
|
119
|
+
return countCommentsByTask(this.db)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { Task } from '../types'
|
|
2
|
+
import type { ProviderSyncStatus, TaskListFilters } from './types'
|
|
3
|
+
|
|
4
|
+
export function parseSyncTimestamp(value: string | null | undefined): number {
|
|
5
|
+
if (!value) return 0
|
|
6
|
+
const parsed = Date.parse(value)
|
|
7
|
+
return Number.isFinite(parsed) ? parsed : 0
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function maxSyncTimestamp(
|
|
11
|
+
a: string | null | undefined,
|
|
12
|
+
b: string | null | undefined,
|
|
13
|
+
): string | null {
|
|
14
|
+
const aMs = parseSyncTimestamp(a)
|
|
15
|
+
const bMs = parseSyncTimestamp(b)
|
|
16
|
+
if (!aMs && !bMs) return null
|
|
17
|
+
return aMs >= bMs ? (a ?? null) : (b ?? null)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class SyncGate {
|
|
21
|
+
private backgroundManaged = false
|
|
22
|
+
|
|
23
|
+
constructor(private readonly pollingSyncIntervalMs: number) {}
|
|
24
|
+
|
|
25
|
+
shouldSkip(params: {
|
|
26
|
+
force: boolean
|
|
27
|
+
viaWarmer: boolean
|
|
28
|
+
lastSyncAt: string | null | undefined
|
|
29
|
+
now?: number
|
|
30
|
+
}): boolean {
|
|
31
|
+
const lastSyncAtMs = parseSyncTimestamp(params.lastSyncAt)
|
|
32
|
+
if (this.backgroundManaged && !params.force && !params.viaWarmer && lastSyncAtMs > 0) {
|
|
33
|
+
return true
|
|
34
|
+
}
|
|
35
|
+
const now = params.now ?? Date.now()
|
|
36
|
+
return !params.force && lastSyncAtMs > 0 && now - lastSyncAtMs < this.pollingSyncIntervalMs
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
setBackgroundManaged(managed: boolean): void {
|
|
40
|
+
this.backgroundManaged = managed
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function syncStatusFromMeta(meta: {
|
|
45
|
+
lastSyncAt: string | null
|
|
46
|
+
lastFullSyncAt: string | null
|
|
47
|
+
lastWebhookAt: string | null
|
|
48
|
+
}): ProviderSyncStatus {
|
|
49
|
+
return {
|
|
50
|
+
lastSyncAt: meta.lastSyncAt,
|
|
51
|
+
lastFullSyncAt: meta.lastFullSyncAt,
|
|
52
|
+
lastWebhookAt: meta.lastWebhookAt,
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function applyTaskFilters(tasks: Task[], filters: TaskListFilters = {}): Task[] {
|
|
57
|
+
let result = tasks
|
|
58
|
+
if (filters.priority) result = result.filter((task) => task.priority === filters.priority)
|
|
59
|
+
if (filters.assignee) result = result.filter((task) => task.assignee === filters.assignee)
|
|
60
|
+
if (filters.project) result = result.filter((task) => task.project === filters.project)
|
|
61
|
+
if (filters.sort === 'title') {
|
|
62
|
+
result = [...result].sort((a, b) => a.title.localeCompare(b.title))
|
|
63
|
+
}
|
|
64
|
+
if (filters.sort === 'updated') {
|
|
65
|
+
result = [...result].sort((a, b) => b.updated_at.localeCompare(a.updated_at))
|
|
66
|
+
}
|
|
67
|
+
if (filters.limit) result = result.slice(0, filters.limit)
|
|
68
|
+
return result
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function mapWithConcurrency<T, R>(
|
|
72
|
+
items: T[],
|
|
73
|
+
concurrency: number,
|
|
74
|
+
mapper: (item: T, index: number) => Promise<R>,
|
|
75
|
+
): Promise<R[]> {
|
|
76
|
+
if (concurrency < 1) throw new RangeError('concurrency must be at least 1')
|
|
77
|
+
const results: R[] = []
|
|
78
|
+
for (let i = 0; i < items.length; i += concurrency) {
|
|
79
|
+
const batch = items.slice(i, i + concurrency)
|
|
80
|
+
results.push(...(await Promise.all(batch.map((item, offset) => mapper(item, i + offset)))))
|
|
81
|
+
}
|
|
82
|
+
return results
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function forEachWithConcurrency<T>(
|
|
86
|
+
items: T[],
|
|
87
|
+
concurrency: number,
|
|
88
|
+
worker: (item: T, index: number) => Promise<void>,
|
|
89
|
+
): Promise<void> {
|
|
90
|
+
await mapWithConcurrency(items, concurrency, worker)
|
|
91
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { ProviderTeamInfo } from '../types'
|
|
2
|
+
|
|
3
|
+
export function parseProviderTeamInfo(raw: string | null): ProviderTeamInfo | null {
|
|
4
|
+
if (raw === null) return null
|
|
5
|
+
try {
|
|
6
|
+
const parsed = JSON.parse(raw) as unknown
|
|
7
|
+
if (
|
|
8
|
+
parsed &&
|
|
9
|
+
typeof parsed === 'object' &&
|
|
10
|
+
'id' in parsed &&
|
|
11
|
+
'key' in parsed &&
|
|
12
|
+
'name' in parsed &&
|
|
13
|
+
typeof (parsed as { id: unknown }).id === 'string' &&
|
|
14
|
+
typeof (parsed as { key: unknown }).key === 'string' &&
|
|
15
|
+
typeof (parsed as { name: unknown }).name === 'string'
|
|
16
|
+
) {
|
|
17
|
+
const team = parsed as { id: string; key: string; name: string }
|
|
18
|
+
return { id: team.id, key: team.key, name: team.name }
|
|
19
|
+
}
|
|
20
|
+
} catch {
|
|
21
|
+
// Corrupt persisted provider metadata should not prevent cache bootstrap.
|
|
22
|
+
}
|
|
23
|
+
return null
|
|
24
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
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
|
+
}
|
|
10
|
+
|
|
11
|
+
// Test-only: clear the process-wide dedup set so tests can assert warnings
|
|
12
|
+
// independently of which test ran first.
|
|
13
|
+
export function resetWarnOnce(): void {
|
|
14
|
+
warnedKeys.clear()
|
|
15
|
+
}
|
package/src/use-cases.ts
CHANGED
|
@@ -1,139 +1,15 @@
|
|
|
1
1
|
import { normalizeLabels } from './labels'
|
|
2
|
-
import type {
|
|
3
|
-
CreateTaskInput,
|
|
4
|
-
KanbanProvider,
|
|
5
|
-
ProviderContext,
|
|
6
|
-
TaskListFilters,
|
|
7
|
-
UpdateTaskInput,
|
|
8
|
-
} from './providers/types'
|
|
9
|
-
import type {
|
|
10
|
-
ActivityEntry,
|
|
11
|
-
BoardBootstrap,
|
|
12
|
-
BoardConfig,
|
|
13
|
-
BoardMetrics,
|
|
14
|
-
BoardView,
|
|
15
|
-
Column,
|
|
16
|
-
Task,
|
|
17
|
-
TaskComment,
|
|
18
|
-
} from './types'
|
|
2
|
+
import type { CreateTaskInput } from './providers/types'
|
|
19
3
|
|
|
20
4
|
/**
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* transport-specific error messages, HTTP routing and WebSocket events, MCP
|
|
26
|
-
* authorization policy and hooks — then delegate the actual provider call
|
|
27
|
-
* here. Keeping that call in one place means cross-cutting concerns (label
|
|
28
|
-
* normalization today; future auditing/validation) have a single insertion
|
|
29
|
-
* point instead of being re-derived in three transports.
|
|
30
|
-
*
|
|
31
|
-
* These functions intentionally do NOT perform required-argument validation:
|
|
32
|
-
* the transports raise their own (differently worded) errors before calling
|
|
33
|
-
* in, so the contract each surface already advertises is preserved.
|
|
34
|
-
*/
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Create-task input as a transport supplies it. Identical to CreateTaskInput
|
|
38
|
-
* except `labels` is accepted in any raw form (CLI flag arrays, a JSON array,
|
|
39
|
-
* a comma-separated string) and normalized here so no transport re-implements
|
|
40
|
-
* the normalization.
|
|
5
|
+
* Create-task input as a transport supplies it. It accepts `labels` in raw
|
|
6
|
+
* transport forms (CLI flag arrays, JSON arrays, comma-separated strings), then
|
|
7
|
+
* normalizes them before the provider call. Other provider operations are direct
|
|
8
|
+
* enough for transports to call the provider without a use-case wrapper.
|
|
41
9
|
*/
|
|
42
10
|
export type CreateTaskCommand = Omit<CreateTaskInput, 'labels'> & { labels?: unknown }
|
|
43
11
|
|
|
44
|
-
export function
|
|
12
|
+
export function normalizeCreateTaskInput(input: CreateTaskCommand): CreateTaskInput {
|
|
45
13
|
const { labels, ...rest } = input
|
|
46
|
-
return
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export function listTasks(provider: KanbanProvider, filters?: TaskListFilters): Promise<Task[]> {
|
|
50
|
-
return provider.listTasks(filters)
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export function getTask(provider: KanbanProvider, idOrRef: string): Promise<Task> {
|
|
54
|
-
return provider.getTask(idOrRef)
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
export function updateTask(
|
|
58
|
-
provider: KanbanProvider,
|
|
59
|
-
idOrRef: string,
|
|
60
|
-
input: UpdateTaskInput,
|
|
61
|
-
): Promise<Task> {
|
|
62
|
-
return provider.updateTask(idOrRef, input)
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
export function moveTask(provider: KanbanProvider, idOrRef: string, column: string): Promise<Task> {
|
|
66
|
-
return provider.moveTask(idOrRef, column)
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
export function deleteTask(provider: KanbanProvider, idOrRef: string): Promise<Task> {
|
|
70
|
-
return provider.deleteTask(idOrRef)
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
export function listComments(provider: KanbanProvider, idOrRef: string): Promise<TaskComment[]> {
|
|
74
|
-
return provider.listComments(idOrRef)
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
export function getComment(
|
|
78
|
-
provider: KanbanProvider,
|
|
79
|
-
idOrRef: string,
|
|
80
|
-
commentId: string,
|
|
81
|
-
): Promise<TaskComment> {
|
|
82
|
-
return provider.getComment(idOrRef, commentId)
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
export function addComment(
|
|
86
|
-
provider: KanbanProvider,
|
|
87
|
-
idOrRef: string,
|
|
88
|
-
body: string,
|
|
89
|
-
): Promise<TaskComment> {
|
|
90
|
-
return provider.comment(idOrRef, body)
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
export function updateComment(
|
|
94
|
-
provider: KanbanProvider,
|
|
95
|
-
idOrRef: string,
|
|
96
|
-
commentId: string,
|
|
97
|
-
body: string,
|
|
98
|
-
): Promise<TaskComment> {
|
|
99
|
-
return provider.updateComment(idOrRef, commentId, body)
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
export function getBoard(provider: KanbanProvider): Promise<BoardView> {
|
|
103
|
-
return provider.getBoard()
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
export function getBootstrap(provider: KanbanProvider): Promise<BoardBootstrap> {
|
|
107
|
-
return provider.getBootstrap()
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
export function getContext(provider: KanbanProvider): Promise<ProviderContext> {
|
|
111
|
-
return provider.getContext()
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
export function listColumns(provider: KanbanProvider): Promise<Column[]> {
|
|
115
|
-
return provider.listColumns()
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
export function getActivity(
|
|
119
|
-
provider: KanbanProvider,
|
|
120
|
-
limit?: number,
|
|
121
|
-
taskId?: string,
|
|
122
|
-
): Promise<ActivityEntry[]> {
|
|
123
|
-
return provider.getActivity(limit, taskId)
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
export function getMetrics(provider: KanbanProvider): Promise<BoardMetrics> {
|
|
127
|
-
return provider.getMetrics()
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
export function getConfig(provider: KanbanProvider): Promise<BoardConfig> {
|
|
131
|
-
return provider.getConfig()
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
export function patchConfig(
|
|
135
|
-
provider: KanbanProvider,
|
|
136
|
-
input: Partial<BoardConfig>,
|
|
137
|
-
): Promise<BoardConfig> {
|
|
138
|
-
return provider.patchConfig(input)
|
|
14
|
+
return { ...rest, labels: normalizeLabels(labels) }
|
|
139
15
|
}
|
package/src/webhook-events.ts
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
import type { JSONValue, Sql } from 'postgres'
|
|
26
26
|
|
|
27
27
|
import type { TrackerProvider } from './tracker-config'
|
|
28
|
-
import type { WebhookResult } from './webhooks'
|
|
28
|
+
import type { WebhookRequest, WebhookResult } from './webhooks'
|
|
29
29
|
|
|
30
30
|
export type WebhookEventStatus = 'accepted' | 'skipped' | 'error'
|
|
31
31
|
|
|
@@ -70,6 +70,34 @@ export function webhookEventStatus(result: WebhookResult): WebhookEventStatus {
|
|
|
70
70
|
return result.handled ? 'accepted' : 'skipped'
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
/** Run a webhook dispatch and record its outcome (accepted/skipped/error) fire-and-forget. */
|
|
74
|
+
export async function withWebhookRecording(
|
|
75
|
+
sql: Sql,
|
|
76
|
+
provider: TrackerProvider,
|
|
77
|
+
payload: WebhookRequest,
|
|
78
|
+
dispatch: () => Promise<WebhookResult>,
|
|
79
|
+
): Promise<WebhookResult> {
|
|
80
|
+
const meta = extractWebhookMeta(provider, payload.rawBody)
|
|
81
|
+
let result: WebhookResult
|
|
82
|
+
try {
|
|
83
|
+
result = await dispatch()
|
|
84
|
+
} catch (err) {
|
|
85
|
+
void recordWebhookEvent(sql, {
|
|
86
|
+
provider,
|
|
87
|
+
...meta,
|
|
88
|
+
status: 'error',
|
|
89
|
+
detail: { error: err instanceof Error ? err.message : String(err) },
|
|
90
|
+
})
|
|
91
|
+
throw err
|
|
92
|
+
}
|
|
93
|
+
void recordWebhookEvent(sql, {
|
|
94
|
+
provider,
|
|
95
|
+
...meta,
|
|
96
|
+
status: webhookEventStatus(result),
|
|
97
|
+
})
|
|
98
|
+
return result
|
|
99
|
+
}
|
|
100
|
+
|
|
73
101
|
/** Append a receipt. Swallows every error — a logging miss must never fail the webhook. */
|
|
74
102
|
export async function recordWebhookEvent(sql: Sql, record: WebhookEventRecord): Promise<void> {
|
|
75
103
|
if (!webhookEventsEnabled()) return
|