@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/store.js CHANGED
@@ -1,15 +1,13 @@
1
- // Хранилище задач доски.
1
+ // Board task storage.
2
2
  //
3
- // У канбана свои поля и своя история, которых в Gitea нет: сессия, модель,
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
- // Колонка в таблице названа `col`, а не `column`: `column` — зарезервированное
8
- // слово SQL, и без экранирования запрос падает. Наружу поле по-прежнему
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
- // Поля `branch` и `worktree` — НАБЛЮДЕНИЕ, вычитанное из issue и событий
12
- // Gitea, а не намерение: плагин ветки не создаёт.
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
- /** Поля задачи, которые разрешено править через updateTask. */
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 { /* игнорируем битый json */ }
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 { /* игнорируем битый json */ }
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 { /* игнорируем битый json */ }
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
- * Каталог и подкаталог бэкапов: 0700 (rwx------)
168
- * Файлы базы, WAL, SHM и файлы бэкапов: 0600 (rw-------)
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
- // поштучно: ALTER на существующий столбец бросает, и это штатный ответ
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
- // Индексы для ускорения выборок активных задач, поиска по Gitea issue и cron-задач
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
- // Повторно обеспечиваем права на созданные/изменённые файлы базы, WAL и SHM
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
- // Ротация: сохраняем только последние 10 бэкапов
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
- /** Задачи, за которыми стоит issue и по которым работа ещё идёт. */
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
- // Задача заведена тогда, когда её завели в Gitea, а не когда подхват
439
- // довёз карточку. `columnAt` остаётся сегодняшним: в колонку она въехала
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
- // Импорт восстанавливает задачу с её прежним id; обычное заведение
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(`задача ${id} не найдена`)
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 = `[Возврат на доработку / Rejection]: ${r}`
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(`задача ${id} не найдена`)
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
- // Удалённая карточка задачи из Gitea запоминается как отказанная: иначе
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
- /** Отказывались ли уже от этого issue. */
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
- /** Задача по issue — чтобы подхват не заводил второй раз то же самое. */
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
- * Возраст считаем по отметке входа в колонку, а не по `updatedAt`: сверка
671
- * трогает задачу и после завершения, и по `updatedAt` архив откладывался
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(`неизвестный источник перехода: ${source}`)
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 {