@goodandready/dsh-kanban 0.2.18 → 0.2.20
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 +29 -54
- package/lib/cron.js +13 -13
- package/lib/events.js +1 -1
- package/lib/filters.js +58 -82
- package/lib/git-provider.js +23 -23
- package/lib/gitea.js +51 -109
- package/lib/github.js +27 -31
- package/lib/import.js +46 -67
- package/lib/index.js +88 -137
- package/lib/intake.js +23 -31
- package/lib/launcher.js +100 -167
- 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 +25 -26
- package/lib/plan.js +23 -45
- package/lib/power.js +2 -2
- package/lib/progress-dump.js +17 -17
- package/lib/routes.js +86 -145
- package/lib/store.js +59 -78
- package/lib/sync.js +56 -98
- package/lib/templates.js +29 -29
- package/lib/time.js +8 -17
- package/lib/transitions.js +36 -57
- package/lib/webhook.js +16 -23
- package/lib/workflow-bridge.js +58 -58
- package/lib/worktree.js +58 -58
- 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 })
|
|
@@ -208,11 +206,9 @@ export function openStore({ dir }) {
|
|
|
208
206
|
db.exec('PRAGMA journal_mode = WAL')
|
|
209
207
|
db.exec('PRAGMA foreign_keys = ON')
|
|
210
208
|
db.exec('PRAGMA busy_timeout = 5000')
|
|
211
|
-
db.exec('PRAGMA busy_timeout = 5000')
|
|
212
209
|
db.exec(SCHEMA)
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
// «уже есть», а не поломка.
|
|
210
|
+
// Database might have been created by an older version. Add missing columns
|
|
211
|
+
// individually: duplicate ALTER TABLE errors are safely caught and ignored.
|
|
216
212
|
for (const [column, definition] of [
|
|
217
213
|
['waiting', 'INTEGER NOT NULL DEFAULT 0'],
|
|
218
214
|
['syncedAt', 'INTEGER NOT NULL DEFAULT 0'],
|
|
@@ -247,14 +243,13 @@ export function openStore({ dir }) {
|
|
|
247
243
|
['workflowRunId', "TEXT NOT NULL DEFAULT ''"],
|
|
248
244
|
['workflowStatus', "TEXT NOT NULL DEFAULT ''"],
|
|
249
245
|
]) {
|
|
250
|
-
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 */ }
|
|
251
247
|
}
|
|
252
|
-
try { db.exec("ALTER TABLE boards ADD COLUMN kind TEXT NOT NULL DEFAULT 'project'") } catch { /*
|
|
253
|
-
//
|
|
254
|
-
// заведения: это честнее нуля, который читался бы как «в 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.
|
|
255
250
|
db.exec('UPDATE tasks SET columnAt = createdAt WHERE columnAt = 0')
|
|
256
251
|
|
|
257
|
-
//
|
|
252
|
+
// Indexes for active tasks, Gitea issue lookups, and cron jobs.
|
|
258
253
|
db.exec('CREATE INDEX IF NOT EXISTS tasks_board_active ON tasks(board, archivedAt, col, position)')
|
|
259
254
|
db.exec('CREATE INDEX IF NOT EXISTS tasks_gitea_issue ON tasks(owner, repo, issueNumber)')
|
|
260
255
|
db.exec('CREATE INDEX IF NOT EXISTS tasks_external_ref ON tasks(externalRef)')
|
|
@@ -266,7 +261,7 @@ export function openStore({ dir }) {
|
|
|
266
261
|
db.exec('PRAGMA synchronous = NORMAL')
|
|
267
262
|
try { db.exec('PRAGMA optimize') } catch { /* pragma optimize best-effort */ }
|
|
268
263
|
|
|
269
|
-
//
|
|
264
|
+
// Enforce secure permissions on newly created or modified DB/WAL/SHM files.
|
|
270
265
|
secureStorePermissions(dir)
|
|
271
266
|
|
|
272
267
|
const q = {
|
|
@@ -317,16 +312,11 @@ export function openStore({ dir }) {
|
|
|
317
312
|
return q.boardById.get(id)
|
|
318
313
|
}
|
|
319
314
|
|
|
320
|
-
//
|
|
321
|
-
//
|
|
322
|
-
//
|
|
323
|
-
// Прежняя единственная доска `main` СТАНОВИТСЯ проектной, а не заводится
|
|
324
|
-
// рядом с новой: заведи мы вторую, задачи остались бы на осиротевшей.
|
|
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.
|
|
325
317
|
for (const board of DEFAULT_BOARDS) ensureBoard(board.id, board.title, board.kind)
|
|
326
|
-
//
|
|
327
|
-
|
|
328
|
-
// название: если его меняли, значит оно чьё-то.
|
|
329
|
-
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')")
|
|
330
320
|
.run(DEFAULT_BOARDS[0].title)
|
|
331
321
|
|
|
332
322
|
function positionFor(board, column, beforeId, afterId) {
|
|
@@ -364,7 +354,7 @@ export function openStore({ dir }) {
|
|
|
364
354
|
copyFileSync(join(dir, 'kanban.db'), dest)
|
|
365
355
|
try { chmodSync(dest, 0o600) } catch { /* ignore */ }
|
|
366
356
|
|
|
367
|
-
//
|
|
357
|
+
// Rotation: retain only the 10 most recent backups.
|
|
368
358
|
try {
|
|
369
359
|
const files = readdirSync(backupsDir)
|
|
370
360
|
.filter((f) => f.startsWith('kanban-backup-') && f.endsWith('.db'))
|
|
@@ -392,7 +382,7 @@ export function openStore({ dir }) {
|
|
|
392
382
|
return ensureBoard(id, title ?? id, kind)
|
|
393
383
|
},
|
|
394
384
|
|
|
395
|
-
/**
|
|
385
|
+
/** Board kind. Unrecognized kinds default to 'project'. */
|
|
396
386
|
boardKind(id) {
|
|
397
387
|
return normalizeKind(q.boardById.get(id)?.kind)
|
|
398
388
|
},
|
|
@@ -406,17 +396,16 @@ export function openStore({ dir }) {
|
|
|
406
396
|
return rowToTask(q.taskById.get(id))
|
|
407
397
|
},
|
|
408
398
|
|
|
409
|
-
/**
|
|
399
|
+
/** Active tasks linked to a Gitea issue currently in progress. */
|
|
410
400
|
listWatchable() {
|
|
411
401
|
return q.watchable.all().map(rowToTask)
|
|
412
402
|
},
|
|
413
403
|
|
|
414
404
|
/**
|
|
415
|
-
*
|
|
405
|
+
* All tasks belonging to a session, ordered by queue time.
|
|
416
406
|
*
|
|
417
|
-
*
|
|
418
|
-
*
|
|
419
|
-
* нескольких, а не брать первую попавшуюся.
|
|
407
|
+
* A single session can manage a batch of tasks, so tools must distinguish
|
|
408
|
+
* single-task from batch workflows.
|
|
420
409
|
*/
|
|
421
410
|
listTasksBySession(sessionId) {
|
|
422
411
|
if (!sessionId) return []
|
|
@@ -436,14 +425,12 @@ export function openStore({ dir }) {
|
|
|
436
425
|
|
|
437
426
|
createTask(input) {
|
|
438
427
|
const now = Date.now()
|
|
439
|
-
//
|
|
440
|
-
//
|
|
441
|
-
// сейчас, и это разные даты.
|
|
428
|
+
// Card creation timestamp reflects when the issue was created in Gitea.
|
|
429
|
+
// columnAt reflects when it entered the column.
|
|
442
430
|
const born = Number(input.createdAt) || now
|
|
443
431
|
const board = input.board ?? 'main'
|
|
444
432
|
const column = input.column ?? 'backlog'
|
|
445
|
-
//
|
|
446
|
-
// генерирует новый.
|
|
433
|
+
// Import restores existing task ID; new creation generates a random UUID.
|
|
447
434
|
const id = typeof input.id === 'string' && input.id !== '' ? input.id : randomUUID()
|
|
448
435
|
const position = positionFor(board, column, input.beforeId, input.afterId)
|
|
449
436
|
q.insertTask.run(
|
|
@@ -486,7 +473,7 @@ export function openStore({ dir }) {
|
|
|
486
473
|
|
|
487
474
|
updateTask(id, patch) {
|
|
488
475
|
const current = q.taskById.get(id)
|
|
489
|
-
if (current === undefined) throw new Error(
|
|
476
|
+
if (current === undefined) throw new Error(`task ${id} not found`)
|
|
490
477
|
const fields = []
|
|
491
478
|
const values = []
|
|
492
479
|
for (const key of WRITABLE) {
|
|
@@ -573,7 +560,7 @@ export function openStore({ dir }) {
|
|
|
573
560
|
const task = rowToTask(q.taskById.get(id))
|
|
574
561
|
if (!task) return undefined
|
|
575
562
|
const r = String(reason ?? '').trim()
|
|
576
|
-
const commentText = `[
|
|
563
|
+
const commentText = `[Rejection]: ${r}`
|
|
577
564
|
this.addComment(id, { author: 'user', role: 'user', text: commentText })
|
|
578
565
|
this.moveTask(id, { column: 'in-progress' })
|
|
579
566
|
this.addTransition({
|
|
@@ -601,17 +588,14 @@ export function openStore({ dir }) {
|
|
|
601
588
|
},
|
|
602
589
|
moveTask(id, { column, beforeId, afterId }) {
|
|
603
590
|
const current = q.taskById.get(id)
|
|
604
|
-
if (current === undefined) throw new Error(
|
|
591
|
+
if (current === undefined) throw new Error(`task ${id} not found`)
|
|
605
592
|
const target = column ?? current.col
|
|
606
593
|
const position = positionFor(current.board, target, beforeId, afterId)
|
|
607
594
|
const now = Date.now()
|
|
608
|
-
//
|
|
609
|
-
//
|
|
610
|
-
// Перестановка внутри колонки отметку НЕ сбрасывает — задача в ревью
|
|
611
|
-
// третий день там и остаётся, как бы её ни двигали вверх-вниз.
|
|
595
|
+
// Time in column is tracked via timestamp rather than querying transition logs.
|
|
596
|
+
// Reordering within the column preserves columnAt.
|
|
612
597
|
const enteredAt = target === current.col ? current.columnAt : now
|
|
613
|
-
//
|
|
614
|
-
// делается. Иначе номер очереди висел бы у карточки, которая идёт.
|
|
598
|
+
// Moving to in-progress resets queuedAt since the task is no longer waiting.
|
|
615
599
|
const queuedAt = target === 'in-progress' ? 0 : current.queuedAt
|
|
616
600
|
db.prepare('UPDATE tasks SET col = ?, position = ?, columnAt = ?, queuedAt = ?, updatedAt = ? WHERE id = ?')
|
|
617
601
|
.run(target, position, enteredAt, queuedAt, now, id)
|
|
@@ -619,9 +603,7 @@ export function openStore({ dir }) {
|
|
|
619
603
|
},
|
|
620
604
|
|
|
621
605
|
deleteTask(id) {
|
|
622
|
-
//
|
|
623
|
-
// подхват вернул бы её на доску следующим же проходом, и доска спорила
|
|
624
|
-
// бы с человеком каждые две минуты.
|
|
606
|
+
// Record deleted Gitea tasks as dismissed to prevent the intake loop from re-importing them.
|
|
625
607
|
const task = q.taskById.get(id)
|
|
626
608
|
if (task?.owner && task?.repo && typeof task.issueNumber === 'number') {
|
|
627
609
|
q.dismiss.run(task.owner, task.repo, task.issueNumber, Date.now())
|
|
@@ -629,12 +611,12 @@ export function openStore({ dir }) {
|
|
|
629
611
|
q.deleteTask.run(id)
|
|
630
612
|
},
|
|
631
613
|
|
|
632
|
-
/**
|
|
614
|
+
/** Whether an issue was previously dismissed. */
|
|
633
615
|
isDismissed({ owner, repo, issueNumber }) {
|
|
634
616
|
return q.isDismissed.get(owner, repo, issueNumber) !== undefined
|
|
635
617
|
},
|
|
636
618
|
|
|
637
|
-
/**
|
|
619
|
+
/** Find task by issue to prevent duplicate import. */
|
|
638
620
|
findTaskByIssue({ owner, repo, issueNumber }) {
|
|
639
621
|
return rowToTask(q.byIssue.get(owner, repo, issueNumber))
|
|
640
622
|
},
|
|
@@ -660,17 +642,16 @@ export function openStore({ dir }) {
|
|
|
660
642
|
return this.updateTask(id, { parentId: parentId ?? '' })
|
|
661
643
|
},
|
|
662
644
|
|
|
663
|
-
/**
|
|
645
|
+
/** Archived tasks list. */
|
|
664
646
|
listArchived() {
|
|
665
647
|
return q.archived.all().map(rowToTask)
|
|
666
648
|
},
|
|
667
649
|
|
|
668
650
|
/**
|
|
669
|
-
*
|
|
651
|
+
* Query tasks ready for archiving.
|
|
670
652
|
*
|
|
671
|
-
*
|
|
672
|
-
*
|
|
673
|
-
* бы бесконечно.
|
|
653
|
+
* Age is calculated based on column entry timestamp rather than updatedAt,
|
|
654
|
+
* as background reconciliation touches updatedAt after completion.
|
|
674
655
|
*/
|
|
675
656
|
listArchivable(before) {
|
|
676
657
|
return q.archivable.all(before).map(rowToTask)
|
|
@@ -683,7 +664,7 @@ export function openStore({ dir }) {
|
|
|
683
664
|
},
|
|
684
665
|
|
|
685
666
|
addTransition({ taskId, fromCol, toCol, source, detail }) {
|
|
686
|
-
if (!SOURCES.has(source)) throw new Error(
|
|
667
|
+
if (!SOURCES.has(source)) throw new Error(`unknown transition source: ${source}`)
|
|
687
668
|
q.insertTransition.run(taskId, fromCol ?? null, toCol, source, detail ?? '', Date.now())
|
|
688
669
|
},
|
|
689
670
|
|
|
@@ -691,14 +672,14 @@ export function openStore({ dir }) {
|
|
|
691
672
|
return q.transitionsOf.all(taskId)
|
|
692
673
|
},
|
|
693
674
|
|
|
694
|
-
/**
|
|
675
|
+
/** Return all transitions; used by metrics calculator across the entire board. */
|
|
695
676
|
listAllTransitions() {
|
|
696
677
|
return q.allTransitions.all()
|
|
697
678
|
},
|
|
698
679
|
|
|
699
680
|
/**
|
|
700
|
-
*
|
|
701
|
-
*
|
|
681
|
+
* Full board snapshot for export: all boards, tasks (including archived),
|
|
682
|
+
* and transition history.
|
|
702
683
|
*/
|
|
703
684
|
exportAll() {
|
|
704
685
|
return {
|