@goodandready/dsh-kanban 0.2.19 → 0.2.21
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/lib/board-tool.js +18 -48
- package/lib/client.js +85 -176
- package/lib/commands.js +21 -38
- package/lib/config.js +30 -55
- package/lib/cron.js +13 -13
- package/lib/events.js +17 -5
- package/lib/filters.js +58 -82
- package/lib/git-provider.js +23 -23
- package/lib/gitea.js +59 -110
- package/lib/github.js +39 -37
- package/lib/import.js +46 -67
- package/lib/index.js +131 -140
- package/lib/intake.js +18 -26
- package/lib/launcher.js +107 -170
- package/lib/lifecycle.js +11 -17
- package/lib/markdown.js +16 -23
- package/lib/metrics.js +23 -25
- package/lib/order.js +25 -28
- package/lib/outbound.js +28 -28
- package/lib/plan.js +23 -45
- package/lib/power.js +2 -2
- package/lib/progress-dump.js +17 -17
- package/lib/routes.js +92 -145
- package/lib/store.js +68 -77
- package/lib/sync.js +56 -98
- package/lib/time.js +8 -17
- package/lib/transitions.js +44 -61
- package/lib/webhook.js +16 -23
- package/lib/workflow-bridge.js +72 -68
- package/lib/worktree.js +103 -60
- package/package.json +1 -1
package/lib/store.js
CHANGED
|
@@ -1,15 +1,13 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Board task storage.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// в issue.
|
|
3
|
+
// Kanban maintains its own fields and history not tracked in Gitea: session,
|
|
4
|
+
// model, transition log, and card ordering. Tasks therefore persist here in SQLite.
|
|
6
5
|
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
// называется `column`, переименование спрятано внутри этого модуля.
|
|
6
|
+
// Column is named `col` in the schema because `column` is a reserved SQL keyword.
|
|
7
|
+
// Externally, the field remains named `column`.
|
|
10
8
|
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
9
|
+
// Fields `branch` and `worktree` are observed from issues and Gitea events rather
|
|
10
|
+
// than driven locally: this plugin does not create branches.
|
|
13
11
|
|
|
14
12
|
import { DatabaseSync } from 'node:sqlite'
|
|
15
13
|
import { randomUUID } from 'node:crypto'
|
|
@@ -98,7 +96,7 @@ CREATE TABLE IF NOT EXISTS dismissed (
|
|
|
98
96
|
);
|
|
99
97
|
`
|
|
100
98
|
|
|
101
|
-
/**
|
|
99
|
+
/** Task fields allowed to be modified via updateTask. */
|
|
102
100
|
const WRITABLE = [
|
|
103
101
|
'title', 'body', 'owner', 'repo', 'issueNumber', 'issueUrl', 'externalRef', 'labels',
|
|
104
102
|
'branch', 'worktree', 'workspaceId', 'sessionId', 'model', 'provider', 'waiting', 'syncedAt', 'plan',
|
|
@@ -108,7 +106,7 @@ const WRITABLE = [
|
|
|
108
106
|
'workflowRunId', 'workflowStatus',
|
|
109
107
|
]
|
|
110
108
|
|
|
111
|
-
/**
|
|
109
|
+
/** Valid transition sources; unexpected values are rejected to preserve log integrity. */
|
|
112
110
|
const SOURCES = new Set(['manual', 'session', 'gitea', 'github', 'tool', 'workflow'])
|
|
113
111
|
|
|
114
112
|
function rowToTask(row) {
|
|
@@ -118,32 +116,32 @@ function rowToTask(row) {
|
|
|
118
116
|
try {
|
|
119
117
|
const value = JSON.parse(checklist ?? '[]')
|
|
120
118
|
if (Array.isArray(value)) parsedChecklist = value
|
|
121
|
-
} catch { /*
|
|
119
|
+
} catch { /* ignore malformed json */ }
|
|
122
120
|
let parsedReport = {}
|
|
123
121
|
try {
|
|
124
122
|
const value = JSON.parse(report ?? '{}')
|
|
125
123
|
if (value !== null && typeof value === 'object' && !Array.isArray(value)) parsedReport = value
|
|
126
|
-
} catch { /*
|
|
124
|
+
} catch { /* ignore malformed json */ }
|
|
127
125
|
let parsedComments = []
|
|
128
126
|
try {
|
|
129
127
|
const value = JSON.parse(comments ?? '[]')
|
|
130
128
|
if (Array.isArray(value)) parsedComments = value
|
|
131
|
-
} catch { /*
|
|
129
|
+
} catch { /* ignore malformed json */ }
|
|
132
130
|
let parsed = []
|
|
133
131
|
try {
|
|
134
132
|
const value = JSON.parse(labels)
|
|
135
133
|
if (Array.isArray(value)) parsed = value.filter((l) => typeof l === 'string')
|
|
136
|
-
} catch { /*
|
|
134
|
+
} catch { /* corrupted field must not fail board read */ }
|
|
137
135
|
let colors = {}
|
|
138
136
|
try {
|
|
139
137
|
const value = JSON.parse(labelColors ?? '{}')
|
|
140
138
|
if (value !== null && typeof value === 'object' && !Array.isArray(value)) colors = value
|
|
141
|
-
} catch { /*
|
|
139
|
+
} catch { /* corrupted field must not fail board read */ }
|
|
142
140
|
let parsedRuns = []
|
|
143
141
|
try {
|
|
144
142
|
const value = JSON.parse(runs ?? '[]')
|
|
145
143
|
if (Array.isArray(value)) parsedRuns = value
|
|
146
|
-
} catch { /*
|
|
144
|
+
} catch { /* corrupted field must not fail board read */ }
|
|
147
145
|
return {
|
|
148
146
|
...rest,
|
|
149
147
|
column: col,
|
|
@@ -163,11 +161,11 @@ function rowToTask(row) {
|
|
|
163
161
|
}
|
|
164
162
|
|
|
165
163
|
/**
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
164
|
+
* Ensure secure file permissions for the storage directory and database files.
|
|
165
|
+
* Directory and backups subdirectory: 0700 (rwx------)
|
|
166
|
+
* Database, WAL, SHM, and backup files: 0600 (rw-------)
|
|
169
167
|
*
|
|
170
|
-
* @param {string} dir
|
|
168
|
+
* @param {string} dir storage directory path
|
|
171
169
|
*/
|
|
172
170
|
export function secureStorePermissions(dir) {
|
|
173
171
|
try {
|
|
@@ -197,9 +195,9 @@ export function secureStorePermissions(dir) {
|
|
|
197
195
|
}
|
|
198
196
|
|
|
199
197
|
/**
|
|
200
|
-
*
|
|
201
|
-
* @param {{dir: string}} options
|
|
202
|
-
* @returns {object}
|
|
198
|
+
* Open task database storage.
|
|
199
|
+
* @param {{dir: string}} options directory holding the database file
|
|
200
|
+
* @returns {object} storage interface
|
|
203
201
|
*/
|
|
204
202
|
export function openStore({ dir }) {
|
|
205
203
|
mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
@@ -209,9 +207,8 @@ export function openStore({ dir }) {
|
|
|
209
207
|
db.exec('PRAGMA foreign_keys = ON')
|
|
210
208
|
db.exec('PRAGMA busy_timeout = 5000')
|
|
211
209
|
db.exec(SCHEMA)
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
// «уже есть», а не поломка.
|
|
210
|
+
// Database might have been created by an older version. Add missing columns
|
|
211
|
+
// individually: duplicate ALTER TABLE errors are safely caught and ignored.
|
|
215
212
|
for (const [column, definition] of [
|
|
216
213
|
['waiting', 'INTEGER NOT NULL DEFAULT 0'],
|
|
217
214
|
['syncedAt', 'INTEGER NOT NULL DEFAULT 0'],
|
|
@@ -246,14 +243,13 @@ export function openStore({ dir }) {
|
|
|
246
243
|
['workflowRunId', "TEXT NOT NULL DEFAULT ''"],
|
|
247
244
|
['workflowStatus', "TEXT NOT NULL DEFAULT ''"],
|
|
248
245
|
]) {
|
|
249
|
-
try { db.exec(`ALTER TABLE tasks ADD COLUMN ${column} ${definition}`) } catch { /*
|
|
246
|
+
try { db.exec(`ALTER TABLE tasks ADD COLUMN ${column} ${definition}`) } catch { /* column already exists */ }
|
|
250
247
|
}
|
|
251
|
-
try { db.exec("ALTER TABLE boards ADD COLUMN kind TEXT NOT NULL DEFAULT 'project'") } catch { /*
|
|
252
|
-
//
|
|
253
|
-
// заведения: это честнее нуля, который читался бы как «в 1970 году».
|
|
248
|
+
try { db.exec("ALTER TABLE boards ADD COLUMN kind TEXT NOT NULL DEFAULT 'project'") } catch { /* already exists */ }
|
|
249
|
+
// Older cards lack column entry timestamps; default to creation timestamp.
|
|
254
250
|
db.exec('UPDATE tasks SET columnAt = createdAt WHERE columnAt = 0')
|
|
255
251
|
|
|
256
|
-
//
|
|
252
|
+
// Indexes for active tasks, Gitea issue lookups, and cron jobs.
|
|
257
253
|
db.exec('CREATE INDEX IF NOT EXISTS tasks_board_active ON tasks(board, archivedAt, col, position)')
|
|
258
254
|
db.exec('CREATE INDEX IF NOT EXISTS tasks_gitea_issue ON tasks(owner, repo, issueNumber)')
|
|
259
255
|
db.exec('CREATE INDEX IF NOT EXISTS tasks_external_ref ON tasks(externalRef)')
|
|
@@ -265,7 +261,7 @@ export function openStore({ dir }) {
|
|
|
265
261
|
db.exec('PRAGMA synchronous = NORMAL')
|
|
266
262
|
try { db.exec('PRAGMA optimize') } catch { /* pragma optimize best-effort */ }
|
|
267
263
|
|
|
268
|
-
//
|
|
264
|
+
// Enforce secure permissions on newly created or modified DB/WAL/SHM files.
|
|
269
265
|
secureStorePermissions(dir)
|
|
270
266
|
|
|
271
267
|
const q = {
|
|
@@ -316,16 +312,11 @@ export function openStore({ dir }) {
|
|
|
316
312
|
return q.boardById.get(id)
|
|
317
313
|
}
|
|
318
314
|
|
|
319
|
-
//
|
|
320
|
-
//
|
|
321
|
-
//
|
|
322
|
-
// Прежняя единственная доска `main` СТАНОВИТСЯ проектной, а не заводится
|
|
323
|
-
// рядом с новой: заведи мы вторую, задачи остались бы на осиротевшей.
|
|
315
|
+
// Initialize default boards so an empty store does not render as broken.
|
|
316
|
+
// The original 'main' board becomes project-kind rather than creating a duplicate.
|
|
324
317
|
for (const board of DEFAULT_BOARDS) ensureBoard(board.id, board.title, board.kind)
|
|
325
|
-
//
|
|
326
|
-
|
|
327
|
-
// название: если его меняли, значит оно чьё-то.
|
|
328
|
-
db.prepare("UPDATE boards SET title = ? WHERE id = 'main' AND title = 'Основная доска'")
|
|
318
|
+
// Rename legacy default title to English if it was untouched.
|
|
319
|
+
db.prepare("UPDATE boards SET title = ? WHERE id = 'main' AND (title = 'Main board' OR title = '\u041e\u0441\u043d\u043e\u0432\u043d\u0430\u044f \u0434\u043e\u0441\u043a\u0430')")
|
|
329
320
|
.run(DEFAULT_BOARDS[0].title)
|
|
330
321
|
|
|
331
322
|
function positionFor(board, column, beforeId, afterId) {
|
|
@@ -363,7 +354,7 @@ export function openStore({ dir }) {
|
|
|
363
354
|
copyFileSync(join(dir, 'kanban.db'), dest)
|
|
364
355
|
try { chmodSync(dest, 0o600) } catch { /* ignore */ }
|
|
365
356
|
|
|
366
|
-
//
|
|
357
|
+
// Rotation: retain only the 10 most recent backups.
|
|
367
358
|
try {
|
|
368
359
|
const files = readdirSync(backupsDir)
|
|
369
360
|
.filter((f) => f.startsWith('kanban-backup-') && f.endsWith('.db'))
|
|
@@ -391,7 +382,7 @@ export function openStore({ dir }) {
|
|
|
391
382
|
return ensureBoard(id, title ?? id, kind)
|
|
392
383
|
},
|
|
393
384
|
|
|
394
|
-
/**
|
|
385
|
+
/** Board kind. Unrecognized kinds default to 'project'. */
|
|
395
386
|
boardKind(id) {
|
|
396
387
|
return normalizeKind(q.boardById.get(id)?.kind)
|
|
397
388
|
},
|
|
@@ -405,17 +396,16 @@ export function openStore({ dir }) {
|
|
|
405
396
|
return rowToTask(q.taskById.get(id))
|
|
406
397
|
},
|
|
407
398
|
|
|
408
|
-
/**
|
|
399
|
+
/** Active tasks linked to a Gitea issue currently in progress. */
|
|
409
400
|
listWatchable() {
|
|
410
401
|
return q.watchable.all().map(rowToTask)
|
|
411
402
|
},
|
|
412
403
|
|
|
413
404
|
/**
|
|
414
|
-
*
|
|
405
|
+
* All tasks belonging to a session, ordered by queue time.
|
|
415
406
|
*
|
|
416
|
-
*
|
|
417
|
-
*
|
|
418
|
-
* нескольких, а не брать первую попавшуюся.
|
|
407
|
+
* A single session can manage a batch of tasks, so tools must distinguish
|
|
408
|
+
* single-task from batch workflows.
|
|
419
409
|
*/
|
|
420
410
|
listTasksBySession(sessionId) {
|
|
421
411
|
if (!sessionId) return []
|
|
@@ -435,14 +425,12 @@ export function openStore({ dir }) {
|
|
|
435
425
|
|
|
436
426
|
createTask(input) {
|
|
437
427
|
const now = Date.now()
|
|
438
|
-
//
|
|
439
|
-
//
|
|
440
|
-
// сейчас, и это разные даты.
|
|
428
|
+
// Card creation timestamp reflects when the issue was created in Gitea.
|
|
429
|
+
// columnAt reflects when it entered the column.
|
|
441
430
|
const born = Number(input.createdAt) || now
|
|
442
431
|
const board = input.board ?? 'main'
|
|
443
432
|
const column = input.column ?? 'backlog'
|
|
444
|
-
//
|
|
445
|
-
// генерирует новый.
|
|
433
|
+
// Import restores existing task ID; new creation generates a random UUID.
|
|
446
434
|
const id = typeof input.id === 'string' && input.id !== '' ? input.id : randomUUID()
|
|
447
435
|
const position = positionFor(board, column, input.beforeId, input.afterId)
|
|
448
436
|
q.insertTask.run(
|
|
@@ -485,7 +473,7 @@ export function openStore({ dir }) {
|
|
|
485
473
|
|
|
486
474
|
updateTask(id, patch) {
|
|
487
475
|
const current = q.taskById.get(id)
|
|
488
|
-
if (current === undefined) throw new Error(
|
|
476
|
+
if (current === undefined) throw new Error(`task ${id} not found`)
|
|
489
477
|
const fields = []
|
|
490
478
|
const values = []
|
|
491
479
|
for (const key of WRITABLE) {
|
|
@@ -553,6 +541,15 @@ export function openStore({ dir }) {
|
|
|
553
541
|
return this.updateTask(id, { comments })
|
|
554
542
|
},
|
|
555
543
|
|
|
544
|
+
addTaskComment(id, comment) {
|
|
545
|
+
if (!comment || typeof comment !== 'object') return undefined
|
|
546
|
+
return this.addComment(id, {
|
|
547
|
+
author: comment.author,
|
|
548
|
+
role: comment.role,
|
|
549
|
+
text: comment.text || comment.body || '',
|
|
550
|
+
})
|
|
551
|
+
},
|
|
552
|
+
|
|
556
553
|
updateChecklist(id, items) {
|
|
557
554
|
const task = rowToTask(q.taskById.get(id))
|
|
558
555
|
if (!task) return undefined
|
|
@@ -572,7 +569,7 @@ export function openStore({ dir }) {
|
|
|
572
569
|
const task = rowToTask(q.taskById.get(id))
|
|
573
570
|
if (!task) return undefined
|
|
574
571
|
const r = String(reason ?? '').trim()
|
|
575
|
-
const commentText = `[
|
|
572
|
+
const commentText = `[Rejection]: ${r}`
|
|
576
573
|
this.addComment(id, { author: 'user', role: 'user', text: commentText })
|
|
577
574
|
this.moveTask(id, { column: 'in-progress' })
|
|
578
575
|
this.addTransition({
|
|
@@ -600,17 +597,14 @@ export function openStore({ dir }) {
|
|
|
600
597
|
},
|
|
601
598
|
moveTask(id, { column, beforeId, afterId }) {
|
|
602
599
|
const current = q.taskById.get(id)
|
|
603
|
-
if (current === undefined) throw new Error(
|
|
600
|
+
if (current === undefined) throw new Error(`task ${id} not found`)
|
|
604
601
|
const target = column ?? current.col
|
|
605
602
|
const position = positionFor(current.board, target, beforeId, afterId)
|
|
606
603
|
const now = Date.now()
|
|
607
|
-
//
|
|
608
|
-
//
|
|
609
|
-
// Перестановка внутри колонки отметку НЕ сбрасывает — задача в ревью
|
|
610
|
-
// третий день там и остаётся, как бы её ни двигали вверх-вниз.
|
|
604
|
+
// Time in column is tracked via timestamp rather than querying transition logs.
|
|
605
|
+
// Reordering within the column preserves columnAt.
|
|
611
606
|
const enteredAt = target === current.col ? current.columnAt : now
|
|
612
|
-
//
|
|
613
|
-
// делается. Иначе номер очереди висел бы у карточки, которая идёт.
|
|
607
|
+
// Moving to in-progress resets queuedAt since the task is no longer waiting.
|
|
614
608
|
const queuedAt = target === 'in-progress' ? 0 : current.queuedAt
|
|
615
609
|
db.prepare('UPDATE tasks SET col = ?, position = ?, columnAt = ?, queuedAt = ?, updatedAt = ? WHERE id = ?')
|
|
616
610
|
.run(target, position, enteredAt, queuedAt, now, id)
|
|
@@ -618,9 +612,7 @@ export function openStore({ dir }) {
|
|
|
618
612
|
},
|
|
619
613
|
|
|
620
614
|
deleteTask(id) {
|
|
621
|
-
//
|
|
622
|
-
// подхват вернул бы её на доску следующим же проходом, и доска спорила
|
|
623
|
-
// бы с человеком каждые две минуты.
|
|
615
|
+
// Record deleted Gitea tasks as dismissed to prevent the intake loop from re-importing them.
|
|
624
616
|
const task = q.taskById.get(id)
|
|
625
617
|
if (task?.owner && task?.repo && typeof task.issueNumber === 'number') {
|
|
626
618
|
q.dismiss.run(task.owner, task.repo, task.issueNumber, Date.now())
|
|
@@ -628,12 +620,12 @@ export function openStore({ dir }) {
|
|
|
628
620
|
q.deleteTask.run(id)
|
|
629
621
|
},
|
|
630
622
|
|
|
631
|
-
/**
|
|
623
|
+
/** Whether an issue was previously dismissed. */
|
|
632
624
|
isDismissed({ owner, repo, issueNumber }) {
|
|
633
625
|
return q.isDismissed.get(owner, repo, issueNumber) !== undefined
|
|
634
626
|
},
|
|
635
627
|
|
|
636
|
-
/**
|
|
628
|
+
/** Find task by issue to prevent duplicate import. */
|
|
637
629
|
findTaskByIssue({ owner, repo, issueNumber }) {
|
|
638
630
|
return rowToTask(q.byIssue.get(owner, repo, issueNumber))
|
|
639
631
|
},
|
|
@@ -659,17 +651,16 @@ export function openStore({ dir }) {
|
|
|
659
651
|
return this.updateTask(id, { parentId: parentId ?? '' })
|
|
660
652
|
},
|
|
661
653
|
|
|
662
|
-
/**
|
|
654
|
+
/** Archived tasks list. */
|
|
663
655
|
listArchived() {
|
|
664
656
|
return q.archived.all().map(rowToTask)
|
|
665
657
|
},
|
|
666
658
|
|
|
667
659
|
/**
|
|
668
|
-
*
|
|
660
|
+
* Query tasks ready for archiving.
|
|
669
661
|
*
|
|
670
|
-
*
|
|
671
|
-
*
|
|
672
|
-
* бы бесконечно.
|
|
662
|
+
* Age is calculated based on column entry timestamp rather than updatedAt,
|
|
663
|
+
* as background reconciliation touches updatedAt after completion.
|
|
673
664
|
*/
|
|
674
665
|
listArchivable(before) {
|
|
675
666
|
return q.archivable.all(before).map(rowToTask)
|
|
@@ -682,7 +673,7 @@ export function openStore({ dir }) {
|
|
|
682
673
|
},
|
|
683
674
|
|
|
684
675
|
addTransition({ taskId, fromCol, toCol, source, detail }) {
|
|
685
|
-
if (!SOURCES.has(source)) throw new Error(
|
|
676
|
+
if (!SOURCES.has(source)) throw new Error(`unknown transition source: ${source}`)
|
|
686
677
|
q.insertTransition.run(taskId, fromCol ?? null, toCol, source, detail ?? '', Date.now())
|
|
687
678
|
},
|
|
688
679
|
|
|
@@ -690,14 +681,14 @@ export function openStore({ dir }) {
|
|
|
690
681
|
return q.transitionsOf.all(taskId)
|
|
691
682
|
},
|
|
692
683
|
|
|
693
|
-
/**
|
|
684
|
+
/** Return all transitions; used by metrics calculator across the entire board. */
|
|
694
685
|
listAllTransitions() {
|
|
695
686
|
return q.allTransitions.all()
|
|
696
687
|
},
|
|
697
688
|
|
|
698
689
|
/**
|
|
699
|
-
*
|
|
700
|
-
*
|
|
690
|
+
* Full board snapshot for export: all boards, tasks (including archived),
|
|
691
|
+
* and transition history.
|
|
701
692
|
*/
|
|
702
693
|
exportAll() {
|
|
703
694
|
return {
|