@goodandready/dsh-kanban 0.1.14

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/routes.js ADDED
@@ -0,0 +1,256 @@
1
+ // Обработчики HTTP доски.
2
+ //
3
+ // Браузерная половина не имеет доступа ни к хранилищу, ни к службам хоста —
4
+ // она ходит сюда. Обработчики написаны так, чтобы проверяться без HTTP:
5
+ // принимают разобранный вход и хранилище, возвращают объект ответа. Обвязка
6
+ // вокруг req/res живёт в `lib/index.js`.
7
+ //
8
+ // Перехвата чужих маршрутов и промежуточных обработчиков в DSH нет:
9
+ // `ctx.webServer.register` — единственный способ.
10
+
11
+ import { COLUMN_ORDER, columnsOf, wipLimitField, columnNamesOf } from './config.js'
12
+ import { parsePlan, planItems, planProgress, taskState, queuePosition } from './plan.js'
13
+ import { isStale } from './time.js'
14
+ import { facetsOf, facetsOfTask, groupByRepo, sortByLife } from './filters.js'
15
+ import { parseBody } from './markdown.js'
16
+
17
+ /** Больше этого тело запроса не читаем: без предела один запрос кладёт хост. */
18
+ export const MAX_BODY_BYTES = 256 * 1024
19
+
20
+ /**
21
+ * Запрос с чужого сайта. Изменяющие маршруты правят локальное состояние, и
22
+ * пускать в них кросс-сайтовый запрос нельзя.
23
+ */
24
+ export function isTrustedRequest(req) {
25
+ return req?.headers?.['sec-fetch-site'] !== 'cross-site'
26
+ }
27
+
28
+ /** Разобрать `/dsh-kanban/task/<id>[/<action>]`; иначе undefined. */
29
+ export function parseTaskPath(pathname) {
30
+ const m = /^\/dsh-kanban\/task\/([^/]+)(?:\/([a-z]+))?\/?$/.exec(pathname ?? '')
31
+ if (m === null) return undefined
32
+ return { id: decodeURIComponent(m[1]), action: m[2] }
33
+ }
34
+
35
+ /**
36
+ * Задача с разобранным планом и состоянием.
37
+ *
38
+ * Сырую строку плана наружу не отдаём: браузерная половина — отдельный бандл,
39
+ * она не может позвать `lib/plan.js`, и разбор пришлось бы писать второй раз.
40
+ *
41
+ * `liveOf` возвращает состояние живого агента сессии либо `undefined`, если
42
+ * агента нет. Без него состояние не вычислить: «остановился» — это факт об
43
+ * отсутствии агента, а не догадка по тишине.
44
+ */
45
+ function withPlan(task, liveOf, config, now, sameSession) {
46
+ const plan = parsePlan(task.plan)
47
+ // Отказ реестра — это «агента нет», а не повод не собрать доску: одна
48
+ // непрочитанная сессия не должна прятать от человека все задачи.
49
+ let live
50
+ try {
51
+ if (typeof liveOf === 'function' && task.sessionId) live = liveOf(task.sessionId)
52
+ } catch { live = undefined }
53
+ return {
54
+ ...task,
55
+ plan: { items: planItems(plan), progress: planProgress(plan) },
56
+ state: taskState({ task, live }),
57
+ // Номер в очереди считается на лету: агент берёт задачи одну за другой, и
58
+ // у оставшихся третий сам становится вторым.
59
+ queuePos: queuePosition(task, sameSession),
60
+ // Метки разобраны здесь же: браузер сверяет списки, а не разбирает строки.
61
+ facets: facetsOfTask(task),
62
+ // Считаем здесь, а не в браузере: порог живёт в настройках, а настройки —
63
+ // на хосте. Отдавать браузеру ещё и порог значило бы отдавать ему решение.
64
+ stale: isStale({
65
+ now,
66
+ updatedAt: task.updatedAt,
67
+ sessionId: task.sessionId,
68
+ afterMinutes: config?.staleAfterMin,
69
+ }),
70
+ }
71
+ }
72
+
73
+ /** Разложить задачи: по колонкам, внутри — по проектам, внутри — идущее наверх. */
74
+ function orderForBoard(tasks, columns) {
75
+ const out = []
76
+ for (const column of columns) {
77
+ const inColumn = tasks.filter((t) => t.column === column)
78
+ for (const group of groupByRepo(sortByLife(inColumn))) out.push(...group.tasks)
79
+ }
80
+ // Задачи в колонке, которой на этой доске нет, не теряем: спрятать их совсем
81
+ // хуже, чем показать в непривычном месте.
82
+ const seen = new Set(out.map((t) => t.id))
83
+ for (const task of tasks) if (!seen.has(task.id)) out.push(task)
84
+ return out
85
+ }
86
+
87
+ /**
88
+ * Ответ доски: колонки в фиксированном порядке, задачи и пределы.
89
+ * Порядок колонок задан кодом, а не настройками: он повторяет воркфлоу, и
90
+ * переставлять его местами незачем.
91
+ */
92
+ export function buildBoard({ store, config, board = 'main', repo, liveOf, sync, projectRoot }) {
93
+ // Одно «сейчас» на всю сборку: иначе соседние карточки посчитались бы по
94
+ // разным моментам, и разница вылезла бы ровно на границе минуты.
95
+ const now = Date.now()
96
+ // Порядок задаём здесь, а не в браузере: правила группировки и подъёма
97
+ // повторять во второй раз значило бы завести вторую их редакцию.
98
+ //
99
+ // Отдаём задачи УЖЕ разложенными — сперва по проектам, внутри проекта идущая
100
+ // работа наверх. Браузер рисует заголовок при смене проекта, и после отбора
101
+ // группы остаются целыми, потому что отбор порядка не меняет.
102
+ const raw = store.listTasks({ board, repo })
103
+ // Очередь считается внутри своей сессии, а не по доске целиком: две сессии —
104
+ // две независимые очереди.
105
+ const bySession = new Map()
106
+ for (const task of raw) {
107
+ const id = typeof task.sessionId === 'string' ? task.sessionId : ''
108
+ if (id === '') continue
109
+ if (!bySession.has(id)) bySession.set(id, [])
110
+ bySession.get(id).push(task)
111
+ }
112
+ const tasks = orderForBoard(
113
+ raw.map((task) => withPlan(task, liveOf, config, now, bySession.get(task.sessionId) ?? [])),
114
+ columnsOf(store.boardKind(board)),
115
+ )
116
+ const kind = store.boardKind(board)
117
+ // Своё название приходит с сервера, а перевод остаётся на браузере: конфиг
118
+ // живёт здесь, а языки — там, и смешивать их не надо.
119
+ const names = columnNamesOf(config)
120
+ const columns = columnsOf(kind).map((id) => {
121
+ const count = tasks.filter((t) => t.column === id).length
122
+ const limit = wipLimitField(config, id)
123
+ return { id, name: names[id], count, limit, overLimit: limit !== undefined && count > limit }
124
+ })
125
+ // Отборы считаем по ВСЕМ задачам доски, а не по отобранным: счётчик рядом со
126
+ // значением отвечает «сколько там всего», а не «сколько осталось после моего
127
+ // же выбора».
128
+ return {
129
+ board, kind, now, boards: store.listBoards(), columns, tasks,
130
+ // Фактический корень проектов: {path, set} — путь и признак «задан
131
+ // настройкой». Считается на хосте — браузер не знает ни настройку, ни cwd.
132
+ projectRoot: projectRoot ?? { path: '', set: false },
133
+
134
+ facets: facetsOf(tasks),
135
+ // Состояние сверки едет вместе с доской: отдельный опрос ради него был бы
136
+ // вторым источником обращений и вторым местом, где это может отстать.
137
+ sync: sync ?? { state: 'never' },
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Перенос карточки. Предел колонки — предупреждение, а не запрет: перенос
143
+ * выполняется, а признак превышения уезжает в ответ, и доска подсветит
144
+ * колонку. Запрет здесь превратил бы предел из ориентира в преграду.
145
+ */
146
+ export function applyMove({ store, config, id, column, beforeId, afterId, source = 'manual', detail = '' }) {
147
+ const before = store.getTask(id)
148
+ if (before === undefined) return { error: 'task-not-found', status: 404 }
149
+ const task = store.moveTask(id, { column, beforeId, afterId })
150
+ if (task.column !== before.column) {
151
+ store.addTransition({
152
+ taskId: id, fromCol: before.column, toCol: task.column, source, detail,
153
+ })
154
+ }
155
+ const limit = wipLimitField(config, task.column)
156
+ const count = store.countInColumn(task.board, task.column)
157
+ return { task, overLimit: limit !== undefined && count > limit }
158
+ }
159
+
160
+ /** Создание своей задачи (не из Gitea). */
161
+ export function createTask({ store, input }) {
162
+ const title = typeof input?.title === 'string' ? input.title.trim() : ''
163
+ if (title === '') return { error: 'title-required', status: 400 }
164
+ const board = input?.board ?? 'main'
165
+ // Колонка проверяется по ВИДУ доски, а не по общему списку: положить задачу
166
+ // в `deploy` на простой доске значило бы спрятать её в колонке, которой там
167
+ // нет.
168
+ const allowed = columnsOf(store.boardKind(board))
169
+ const column = allowed.includes(input?.column) ? input.column : 'backlog'
170
+ const task = store.createTask({
171
+ board,
172
+ column,
173
+ title,
174
+ body: typeof input?.body === 'string' ? input.body : '',
175
+ labels: Array.isArray(input?.labels) ? input.labels.filter((l) => typeof l === 'string') : [],
176
+ })
177
+ return { task }
178
+ }
179
+
180
+ /** Правка полей карточки. Колонку и порядок правит только перенос. */
181
+ export function updateTask({ store, id, input }) {
182
+ if (store.getTask(id) === undefined) return { error: 'task-not-found', status: 404 }
183
+ const patch = {}
184
+ if (typeof input?.title === 'string' && input.title.trim() !== '') patch.title = input.title.trim()
185
+ if (typeof input?.body === 'string') patch.body = input.body
186
+ if (Array.isArray(input?.labels)) patch.labels = input.labels.filter((l) => typeof l === 'string')
187
+ return { task: store.updateTask(id, patch) }
188
+ }
189
+
190
+ /**
191
+ * Дописать заметку в тело задачи.
192
+ *
193
+ * Именно в тело, а не в отдельную ленту заметок и не комментарием в Gitea:
194
+ * владелец выбрал самый простой из трёх смыслов, и он же самый дешёвый.
195
+ *
196
+ * Дописываем на сервере, а не в браузере: чип держит копию задачи, и склейка
197
+ * у него потеряла бы всё, что тело нажило с момента её получения.
198
+ */
199
+ export function appendNote({ store, id, input }) {
200
+ const task = store.getTask(id)
201
+ if (task === undefined) return { error: 'task-not-found', status: 404 }
202
+ const note = typeof input?.text === 'string' ? input.text.trim() : ''
203
+ if (note === '') return { error: 'note-required', status: 400 }
204
+ const body = typeof task.body === 'string' ? task.body : ''
205
+ return { task: store.updateTask(id, { body: body === '' ? note : `${body}\n\n${note}` }) }
206
+ }
207
+
208
+ /**
209
+ * Убрать карточку в архив или вернуть на доску.
210
+ *
211
+ * Возврат ставит её ровно туда, где она была: колонка и порядок сохранены, а
212
+ * `archivedAt` — единственное, что менялось.
213
+ */
214
+ export function setArchived({ store, id, archived, now = Date.now() }) {
215
+ const task = store.getTask(id)
216
+ if (task === undefined) return { error: 'task-not-found', status: 404 }
217
+ return { task: store.setArchived(id, archived ? now : 0) }
218
+ }
219
+
220
+ /** Архив: свой список, а не колонка — архив стадией работы не является. */
221
+ export function listArchive({ store }) {
222
+ return { tasks: store.listArchived() }
223
+ }
224
+
225
+ export function deleteTask({ store, id }) {
226
+ if (store.getTask(id) === undefined) return { error: 'task-not-found', status: 404 }
227
+ store.deleteTask(id)
228
+ return { ok: true }
229
+ }
230
+
231
+ /**
232
+ * Тело задачи, разобранное в дерево блоков.
233
+ *
234
+ * Разбираем здесь, а не в браузере: браузерная половина — отдельный бандл и
235
+ * позвать `lib/markdown.js` не может. Заодно тело не едет в разметку строкой,
236
+ * и подставить туда чужое нечем.
237
+ */
238
+ export function taskBody({ store, id }) {
239
+ const task = store.getTask(id)
240
+ if (task === undefined) return { error: 'task-not-found', status: 404 }
241
+ return { blocks: parseBody(task.body) }
242
+ }
243
+
244
+ export function taskLog({ store, id }) {
245
+ if (store.getTask(id) === undefined) return { error: 'task-not-found', status: 404 }
246
+ return { transitions: store.listTransitions(id) }
247
+ }
248
+
249
+ /** Задача, привязанная к сессии; отсутствие задачи — штатный ответ. */
250
+ export function taskBySession({ store, sessionId }) {
251
+ const task = store.findTaskBySession(sessionId)
252
+ if (task === undefined) return { task: null }
253
+ // Вид доски едет вместе с задачей: чип предлагает колонки, а предлагать
254
+ // `deploy` задаче с простой доски значит звать в колонку, которой нет.
255
+ return { task, columns: columnsOf(store.boardKind(task.board)) }
256
+ }
package/lib/store.js ADDED
@@ -0,0 +1,366 @@
1
+ // Хранилище задач доски.
2
+ //
3
+ // У канбана свои поля и своя история, которых в Gitea нет: сессия, модель,
4
+ // журнал переходов, порядок карточек. Поэтому задачи живут здесь, а не только
5
+ // в issue.
6
+ //
7
+ // Колонка в таблице названа `col`, а не `column`: `column` — зарезервированное
8
+ // слово SQL, и без экранирования запрос падает. Наружу поле по-прежнему
9
+ // называется `column`, переименование спрятано внутри этого модуля.
10
+ //
11
+ // Поля `branch` и `worktree` — НАБЛЮДЕНИЕ, вычитанное из issue и событий
12
+ // Gitea, а не намерение: плагин ветки не создаёт.
13
+
14
+ import { DatabaseSync } from 'node:sqlite'
15
+ import { randomUUID } from 'node:crypto'
16
+ import { mkdirSync } from 'node:fs'
17
+ import { join } from 'node:path'
18
+ import { keyBetween } from './order.js'
19
+ import { DEFAULT_BOARDS, normalizeKind } from './config.js'
20
+
21
+ const SCHEMA = `
22
+ CREATE TABLE IF NOT EXISTS boards (
23
+ id TEXT PRIMARY KEY,
24
+ title TEXT NOT NULL,
25
+ kind TEXT NOT NULL DEFAULT 'project',
26
+ position TEXT NOT NULL,
27
+ createdAt INTEGER NOT NULL
28
+ );
29
+ CREATE TABLE IF NOT EXISTS tasks (
30
+ id TEXT PRIMARY KEY,
31
+ board TEXT NOT NULL,
32
+ col TEXT NOT NULL,
33
+ position TEXT NOT NULL,
34
+ title TEXT NOT NULL,
35
+ body TEXT NOT NULL DEFAULT '',
36
+ owner TEXT,
37
+ repo TEXT,
38
+ issueNumber INTEGER,
39
+ issueUrl TEXT,
40
+ labels TEXT NOT NULL DEFAULT '[]',
41
+ branch TEXT,
42
+ worktree TEXT,
43
+ sessionId TEXT,
44
+ model TEXT,
45
+ provider TEXT,
46
+ waiting INTEGER NOT NULL DEFAULT 0,
47
+ plan TEXT NOT NULL DEFAULT '',
48
+ columnAt INTEGER NOT NULL DEFAULT 0,
49
+ archivedAt INTEGER NOT NULL DEFAULT 0,
50
+ queuedAt INTEGER NOT NULL DEFAULT 0,
51
+ labelColors TEXT NOT NULL DEFAULT '{}',
52
+ author TEXT NOT NULL DEFAULT '',
53
+ syncedAt INTEGER NOT NULL DEFAULT 0,
54
+ createdAt INTEGER NOT NULL,
55
+ updatedAt INTEGER NOT NULL
56
+ );
57
+ CREATE INDEX IF NOT EXISTS tasks_board_col ON tasks(board, col, position);
58
+ CREATE INDEX IF NOT EXISTS tasks_session ON tasks(sessionId);
59
+ CREATE TABLE IF NOT EXISTS transitions (
60
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
61
+ taskId TEXT NOT NULL,
62
+ fromCol TEXT,
63
+ toCol TEXT NOT NULL,
64
+ source TEXT NOT NULL,
65
+ detail TEXT NOT NULL DEFAULT '',
66
+ at INTEGER NOT NULL
67
+ );
68
+ CREATE INDEX IF NOT EXISTS transitions_task ON transitions(taskId, at);
69
+ CREATE TABLE IF NOT EXISTS dismissed (
70
+ owner TEXT NOT NULL,
71
+ repo TEXT NOT NULL,
72
+ issueNumber INTEGER NOT NULL,
73
+ at INTEGER NOT NULL,
74
+ PRIMARY KEY (owner, repo, issueNumber)
75
+ );
76
+ `
77
+
78
+ /** Поля задачи, которые разрешено править через updateTask. */
79
+ const WRITABLE = [
80
+ 'title', 'body', 'owner', 'repo', 'issueNumber', 'issueUrl', 'labels',
81
+ 'branch', 'worktree', 'sessionId', 'model', 'provider', 'waiting', 'syncedAt', 'plan',
82
+ 'labelColors', 'queuedAt', 'createdAt', 'author',
83
+ ]
84
+
85
+ /** Источники перехода; чужое значение не пишем, чтобы журнал оставался читаемым. */
86
+ const SOURCES = new Set(['manual', 'session', 'gitea', 'tool'])
87
+
88
+ function rowToTask(row) {
89
+ if (row === undefined) return undefined
90
+ const { col, labels, waiting, labelColors, ...rest } = row
91
+ let parsed = []
92
+ try {
93
+ const value = JSON.parse(labels)
94
+ if (Array.isArray(value)) parsed = value.filter((l) => typeof l === 'string')
95
+ } catch { /* испорченное поле не должно ронять чтение всей доски */ }
96
+ let colors = {}
97
+ try {
98
+ const value = JSON.parse(labelColors ?? '{}')
99
+ if (value !== null && typeof value === 'object' && !Array.isArray(value)) colors = value
100
+ } catch { /* испорченное поле не должно ронять чтение всей доски */ }
101
+ return { ...rest, column: col, labels: parsed, labelColors: colors, waiting: waiting === 1 }
102
+ }
103
+
104
+ /**
105
+ * Открыть хранилище задач.
106
+ * @param {{dir: string}} options каталог, в котором лежит файл базы
107
+ * @returns {object} интерфейс хранилища
108
+ */
109
+ export function openStore({ dir }) {
110
+ mkdirSync(dir, { recursive: true })
111
+ const db = new DatabaseSync(join(dir, 'kanban.db'))
112
+ db.exec('PRAGMA journal_mode = WAL')
113
+ db.exec('PRAGMA foreign_keys = ON')
114
+ db.exec(SCHEMA)
115
+ // База могла быть создана прежней версией. Добавляем недостающие столбцы
116
+ // поштучно: ALTER на существующий столбец бросает, и это штатный ответ
117
+ // «уже есть», а не поломка.
118
+ for (const [column, definition] of [
119
+ ['waiting', 'INTEGER NOT NULL DEFAULT 0'],
120
+ ['syncedAt', 'INTEGER NOT NULL DEFAULT 0'],
121
+ ['plan', "TEXT NOT NULL DEFAULT ''"],
122
+ ['columnAt', 'INTEGER NOT NULL DEFAULT 0'],
123
+ ['archivedAt', 'INTEGER NOT NULL DEFAULT 0'],
124
+ ['queuedAt', 'INTEGER NOT NULL DEFAULT 0'],
125
+ ['labelColors', "TEXT NOT NULL DEFAULT '{}'"],
126
+ ['author', "TEXT NOT NULL DEFAULT ''"],
127
+ ]) {
128
+ try { db.exec(`ALTER TABLE tasks ADD COLUMN ${column} ${definition}`) } catch { /* столбец уже есть */ }
129
+ }
130
+ try { db.exec("ALTER TABLE boards ADD COLUMN kind TEXT NOT NULL DEFAULT 'project'") } catch { /* уже есть */ }
131
+ // Старые карточки не знают, когда попали в свою колонку. Считаем от
132
+ // заведения: это честнее нуля, который читался бы как «в 1970 году».
133
+ db.exec('UPDATE tasks SET columnAt = createdAt WHERE columnAt = 0')
134
+
135
+ const q = {
136
+ boards: db.prepare('SELECT * FROM boards ORDER BY position'),
137
+ boardById: db.prepare('SELECT * FROM boards WHERE id = ?'),
138
+ insertBoard: db.prepare('INSERT INTO boards (id, title, kind, position, createdAt) VALUES (?, ?, ?, ?, ?)'),
139
+ lastBoard: db.prepare('SELECT position FROM boards ORDER BY position DESC LIMIT 1'),
140
+ taskById: db.prepare('SELECT * FROM tasks WHERE id = ?'),
141
+ tasksByBoard: db.prepare('SELECT * FROM tasks WHERE board = ? AND archivedAt = 0 ORDER BY col, position'),
142
+ archived: db.prepare('SELECT * FROM tasks WHERE archivedAt > 0 ORDER BY archivedAt DESC'),
143
+ archivable: db.prepare(
144
+ "SELECT * FROM tasks WHERE archivedAt = 0 AND col = 'done' AND columnAt > 0 AND columnAt <= ?"),
145
+ dismiss: db.prepare('INSERT OR REPLACE INTO dismissed (owner, repo, issueNumber, at) VALUES (?, ?, ?, ?)'),
146
+ isDismissed: db.prepare('SELECT 1 FROM dismissed WHERE owner = ? AND repo = ? AND issueNumber = ?'),
147
+ byIssue: db.prepare('SELECT * FROM tasks WHERE owner = ? AND repo = ? AND issueNumber = ?'),
148
+ tasksBySession: db.prepare('SELECT * FROM tasks WHERE sessionId = ? LIMIT 1'),
149
+ lastInColumn: db.prepare('SELECT position FROM tasks WHERE board = ? AND col = ? ORDER BY position DESC LIMIT 1'),
150
+ deleteTask: db.prepare('DELETE FROM tasks WHERE id = ?'),
151
+ insertTransition: db.prepare('INSERT INTO transitions (taskId, fromCol, toCol, source, detail, at) VALUES (?, ?, ?, ?, ?, ?)'),
152
+ transitionsOf: db.prepare('SELECT * FROM transitions WHERE taskId = ? ORDER BY at, id'),
153
+ countInColumn: db.prepare('SELECT COUNT(*) AS n FROM tasks WHERE board = ? AND col = ?'),
154
+ watchable: db.prepare(
155
+ "SELECT * FROM tasks WHERE issueNumber IS NOT NULL AND col NOT IN ('done') ORDER BY updatedAt DESC"),
156
+ }
157
+
158
+ function ensureBoard(id, title, kind) {
159
+ const found = q.boardById.get(id)
160
+ if (found !== undefined) return found
161
+ const last = q.lastBoard.get()
162
+ const position = keyBetween(last?.position, undefined)
163
+ q.insertBoard.run(id, title, normalizeKind(kind), position, Date.now())
164
+ return q.boardById.get(id)
165
+ }
166
+
167
+ // Обе доски заводятся сразу: пустое хранилище без единой доски выглядит в
168
+ // интерфейсе как поломка, а не как чистый старт.
169
+ //
170
+ // Прежняя единственная доска `main` СТАНОВИТСЯ проектной, а не заводится
171
+ // рядом с новой: заведи мы вторую, задачи остались бы на осиротевшей.
172
+ for (const board of DEFAULT_BOARDS) ensureBoard(board.id, board.title, board.kind)
173
+ // Прежняя доска называлась «Основная», и рядом с «Простой» это читается как
174
+ // разница в важности, а не в виде. Переименовываем только нетронутое
175
+ // название: если его меняли, значит оно чьё-то.
176
+ db.prepare("UPDATE boards SET title = ? WHERE id = 'main' AND title = 'Основная доска'")
177
+ .run(DEFAULT_BOARDS[0].title)
178
+
179
+ function positionFor(board, column, beforeId, afterId) {
180
+ const before = beforeId ? q.taskById.get(beforeId) : undefined
181
+ const after = afterId ? q.taskById.get(afterId) : undefined
182
+ if (before === undefined && after === undefined) {
183
+ return keyBetween(q.lastInColumn.get(board, column)?.position, undefined)
184
+ }
185
+ return keyBetween(after?.position, before?.position)
186
+ }
187
+
188
+ return {
189
+ listBoards() {
190
+ return q.boards.all()
191
+ },
192
+
193
+ createBoard({ id, title, kind }) {
194
+ return ensureBoard(id, title ?? id, kind)
195
+ },
196
+
197
+ /** Вид доски. Неизвестная доска — проектная: пустого набора колонок нет. */
198
+ boardKind(id) {
199
+ return normalizeKind(q.boardById.get(id)?.kind)
200
+ },
201
+
202
+ listTasks({ board = 'main', repo } = {}) {
203
+ const rows = q.tasksByBoard.all(board).map(rowToTask)
204
+ return repo ? rows.filter((t) => t.repo === repo) : rows
205
+ },
206
+
207
+ getTask(id) {
208
+ return rowToTask(q.taskById.get(id))
209
+ },
210
+
211
+ /** Задачи, за которыми стоит issue и по которым работа ещё идёт. */
212
+ listWatchable() {
213
+ return q.watchable.all().map(rowToTask)
214
+ },
215
+
216
+ /**
217
+ * ВСЕ задачи сессии, в порядке постановки в очередь.
218
+ *
219
+ * Одна сессия ведёт пачку задач, и «чья это сессия» перестало иметь
220
+ * единственный ответ. Инструменты обязаны различать один случай от
221
+ * нескольких, а не брать первую попавшуюся.
222
+ */
223
+ listTasksBySession(sessionId) {
224
+ if (!sessionId) return []
225
+ return db.prepare(
226
+ 'SELECT * FROM tasks WHERE sessionId = ? AND archivedAt = 0 ORDER BY queuedAt, col, position')
227
+ .all(sessionId).map(rowToTask)
228
+ },
229
+
230
+ findTaskBySession(sessionId) {
231
+ if (!sessionId) return undefined
232
+ return rowToTask(q.tasksBySession.get(sessionId))
233
+ },
234
+
235
+ countInColumn(board, column) {
236
+ return q.countInColumn.get(board, column).n
237
+ },
238
+
239
+ createTask(input) {
240
+ const now = Date.now()
241
+ // Задача заведена тогда, когда её завели в Gitea, а не когда подхват
242
+ // довёз карточку. `columnAt` остаётся сегодняшним: в колонку она въехала
243
+ // сейчас, и это разные даты.
244
+ const born = Number(input.createdAt) || now
245
+ const board = input.board ?? 'main'
246
+ const column = input.column ?? 'backlog'
247
+ const id = randomUUID()
248
+ const position = positionFor(board, column, input.beforeId, input.afterId)
249
+ db.prepare(`INSERT INTO tasks
250
+ (id, board, col, position, title, body, owner, repo, issueNumber, issueUrl,
251
+ labels, branch, worktree, sessionId, model, provider, waiting, syncedAt,
252
+ labelColors, author, columnAt, createdAt, updatedAt)
253
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
254
+ id, board, column, position,
255
+ input.title ?? '', input.body ?? '',
256
+ input.owner ?? null, input.repo ?? null,
257
+ input.issueNumber ?? null, input.issueUrl ?? null,
258
+ JSON.stringify(input.labels ?? []),
259
+ input.branch ?? null, input.worktree ?? null,
260
+ input.sessionId ?? null, input.model ?? null, input.provider ?? null,
261
+ input.waiting ? 1 : 0,
262
+ Number(input.syncedAt) || 0,
263
+ JSON.stringify(input.labelColors ?? {}),
264
+ input.author ?? '',
265
+ now, born, now,
266
+ )
267
+ return rowToTask(q.taskById.get(id))
268
+ },
269
+
270
+ updateTask(id, patch) {
271
+ const current = q.taskById.get(id)
272
+ if (current === undefined) throw new Error(`задача ${id} не найдена`)
273
+ const fields = []
274
+ const values = []
275
+ for (const key of WRITABLE) {
276
+ if (!Object.hasOwn(patch, key)) continue
277
+ fields.push(`${key} = ?`)
278
+ if (key === 'labels') values.push(JSON.stringify(patch[key] ?? []))
279
+ else if (key === 'labelColors') values.push(JSON.stringify(patch[key] ?? {}))
280
+ else if (key === 'waiting') values.push(patch[key] ? 1 : 0)
281
+ else if (key === 'syncedAt') values.push(Number(patch[key]) || 0)
282
+ else values.push(patch[key] ?? null)
283
+ }
284
+ if (fields.length > 0) {
285
+ values.push(Date.now(), id)
286
+ db.prepare(`UPDATE tasks SET ${fields.join(', ')}, updatedAt = ? WHERE id = ?`).run(...values)
287
+ }
288
+ return rowToTask(q.taskById.get(id))
289
+ },
290
+
291
+ moveTask(id, { column, beforeId, afterId }) {
292
+ const current = q.taskById.get(id)
293
+ if (current === undefined) throw new Error(`задача ${id} не найдена`)
294
+ const target = column ?? current.col
295
+ const position = positionFor(current.board, target, beforeId, afterId)
296
+ const now = Date.now()
297
+ // Время в колонке считаем по отметке, а не по журналу переходов: журнал
298
+ // пришлось бы читать на каждую карточку при каждой сборке доски.
299
+ // Перестановка внутри колонки отметку НЕ сбрасывает — задача в ревью
300
+ // третий день там и остаётся, как бы её ни двигали вверх-вниз.
301
+ const enteredAt = target === current.col ? current.columnAt : now
302
+ // Переезд в работу снимает задачу с очереди: она уже не ждёт, а
303
+ // делается. Иначе номер очереди висел бы у карточки, которая идёт.
304
+ const queuedAt = target === 'in-progress' ? 0 : current.queuedAt
305
+ db.prepare('UPDATE tasks SET col = ?, position = ?, columnAt = ?, queuedAt = ?, updatedAt = ? WHERE id = ?')
306
+ .run(target, position, enteredAt, queuedAt, now, id)
307
+ return rowToTask(q.taskById.get(id))
308
+ },
309
+
310
+ deleteTask(id) {
311
+ // Удалённая карточка задачи из Gitea запоминается как отказанная: иначе
312
+ // подхват вернул бы её на доску следующим же проходом, и доска спорила
313
+ // бы с человеком каждые две минуты.
314
+ const task = q.taskById.get(id)
315
+ if (task?.owner && task?.repo && typeof task.issueNumber === 'number') {
316
+ q.dismiss.run(task.owner, task.repo, task.issueNumber, Date.now())
317
+ }
318
+ q.deleteTask.run(id)
319
+ },
320
+
321
+ /** Отказывались ли уже от этого issue. */
322
+ isDismissed({ owner, repo, issueNumber }) {
323
+ return q.isDismissed.get(owner, repo, issueNumber) !== undefined
324
+ },
325
+
326
+ /** Задача по issue — чтобы подхват не заводил второй раз то же самое. */
327
+ findTaskByIssue({ owner, repo, issueNumber }) {
328
+ return rowToTask(q.byIssue.get(owner, repo, issueNumber))
329
+ },
330
+
331
+ /** Задачи в архиве: свой список, а не колонка. */
332
+ listArchived() {
333
+ return q.archived.all().map(rowToTask)
334
+ },
335
+
336
+ /**
337
+ * Что пора убрать в архив.
338
+ *
339
+ * Возраст считаем по отметке входа в колонку, а не по `updatedAt`: сверка
340
+ * трогает задачу и после завершения, и по `updatedAt` архив откладывался
341
+ * бы бесконечно.
342
+ */
343
+ listArchivable(before) {
344
+ return q.archivable.all(before).map(rowToTask)
345
+ },
346
+
347
+ setArchived(id, archivedAt) {
348
+ db.prepare('UPDATE tasks SET archivedAt = ?, updatedAt = ? WHERE id = ?')
349
+ .run(archivedAt, Date.now(), id)
350
+ return rowToTask(q.taskById.get(id))
351
+ },
352
+
353
+ addTransition({ taskId, fromCol, toCol, source, detail }) {
354
+ if (!SOURCES.has(source)) throw new Error(`неизвестный источник перехода: ${source}`)
355
+ q.insertTransition.run(taskId, fromCol ?? null, toCol, source, detail ?? '', Date.now())
356
+ },
357
+
358
+ listTransitions(taskId) {
359
+ return q.transitionsOf.all(taskId)
360
+ },
361
+
362
+ close() {
363
+ db.close()
364
+ },
365
+ }
366
+ }