@goodandready/dsh-kanban 0.1.20 → 0.1.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/README.md +19 -1
- package/lib/client.js +34 -0
- package/lib/filters.js +307 -288
- package/lib/gitea.js +28 -0
- package/lib/import.js +23 -0
- package/lib/index.js +25 -2
- package/lib/outbound.js +22 -0
- package/lib/routes.js +355 -331
- package/lib/store.js +392 -389
- package/lib/sync.js +7 -1
- package/package.json +1 -1
package/lib/store.js
CHANGED
|
@@ -1,389 +1,392 @@
|
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
CREATE INDEX IF NOT EXISTS
|
|
61
|
-
CREATE
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
CREATE
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
)
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
'
|
|
84
|
-
'
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
*
|
|
109
|
-
* @
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
db
|
|
115
|
-
db.exec('PRAGMA
|
|
116
|
-
db.exec(
|
|
117
|
-
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
['
|
|
123
|
-
['
|
|
124
|
-
['
|
|
125
|
-
['
|
|
126
|
-
['
|
|
127
|
-
['
|
|
128
|
-
['
|
|
129
|
-
['
|
|
130
|
-
['
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
|
|
179
|
-
//
|
|
180
|
-
|
|
181
|
-
//
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
//
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
const
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
input.
|
|
266
|
-
|
|
267
|
-
input.
|
|
268
|
-
|
|
269
|
-
input.
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
input.
|
|
273
|
-
input.
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
const
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
else values.push(patch[key] ??
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
values.push(
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
const
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
//
|
|
311
|
-
|
|
312
|
-
//
|
|
313
|
-
//
|
|
314
|
-
const
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
*
|
|
351
|
-
*
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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
|
+
assignee TEXT NOT NULL DEFAULT '',
|
|
54
|
+
priority TEXT NOT NULL DEFAULT '',
|
|
55
|
+
dueAt INTEGER NOT NULL DEFAULT 0,
|
|
56
|
+
syncedAt INTEGER NOT NULL DEFAULT 0,
|
|
57
|
+
createdAt INTEGER NOT NULL,
|
|
58
|
+
updatedAt INTEGER NOT NULL
|
|
59
|
+
);
|
|
60
|
+
CREATE INDEX IF NOT EXISTS tasks_board_col ON tasks(board, col, position);
|
|
61
|
+
CREATE INDEX IF NOT EXISTS tasks_session ON tasks(sessionId);
|
|
62
|
+
CREATE TABLE IF NOT EXISTS transitions (
|
|
63
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
64
|
+
taskId TEXT NOT NULL,
|
|
65
|
+
fromCol TEXT,
|
|
66
|
+
toCol TEXT NOT NULL,
|
|
67
|
+
source TEXT NOT NULL,
|
|
68
|
+
detail TEXT NOT NULL DEFAULT '',
|
|
69
|
+
at INTEGER NOT NULL
|
|
70
|
+
);
|
|
71
|
+
CREATE INDEX IF NOT EXISTS transitions_task ON transitions(taskId, at);
|
|
72
|
+
CREATE TABLE IF NOT EXISTS dismissed (
|
|
73
|
+
owner TEXT NOT NULL,
|
|
74
|
+
repo TEXT NOT NULL,
|
|
75
|
+
issueNumber INTEGER NOT NULL,
|
|
76
|
+
at INTEGER NOT NULL,
|
|
77
|
+
PRIMARY KEY (owner, repo, issueNumber)
|
|
78
|
+
);
|
|
79
|
+
`
|
|
80
|
+
|
|
81
|
+
/** Поля задачи, которые разрешено править через updateTask. */
|
|
82
|
+
const WRITABLE = [
|
|
83
|
+
'title', 'body', 'owner', 'repo', 'issueNumber', 'issueUrl', 'labels',
|
|
84
|
+
'branch', 'worktree', 'sessionId', 'model', 'provider', 'waiting', 'syncedAt', 'plan',
|
|
85
|
+
'labelColors', 'queuedAt', 'createdAt', 'author', 'assignee', 'priority', 'dueAt',
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
/** Источники перехода; чужое значение не пишем, чтобы журнал оставался читаемым. */
|
|
89
|
+
const SOURCES = new Set(['manual', 'session', 'gitea', 'tool'])
|
|
90
|
+
|
|
91
|
+
function rowToTask(row) {
|
|
92
|
+
if (row === undefined) return undefined
|
|
93
|
+
const { col, labels, waiting, labelColors, ...rest } = row
|
|
94
|
+
let parsed = []
|
|
95
|
+
try {
|
|
96
|
+
const value = JSON.parse(labels)
|
|
97
|
+
if (Array.isArray(value)) parsed = value.filter((l) => typeof l === 'string')
|
|
98
|
+
} catch { /* испорченное поле не должно ронять чтение всей доски */ }
|
|
99
|
+
let colors = {}
|
|
100
|
+
try {
|
|
101
|
+
const value = JSON.parse(labelColors ?? '{}')
|
|
102
|
+
if (value !== null && typeof value === 'object' && !Array.isArray(value)) colors = value
|
|
103
|
+
} catch { /* испорченное поле не должно ронять чтение всей доски */ }
|
|
104
|
+
return { ...rest, column: col, labels: parsed, labelColors: colors, waiting: waiting === 1 }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Открыть хранилище задач.
|
|
109
|
+
* @param {{dir: string}} options каталог, в котором лежит файл базы
|
|
110
|
+
* @returns {object} интерфейс хранилища
|
|
111
|
+
*/
|
|
112
|
+
export function openStore({ dir }) {
|
|
113
|
+
mkdirSync(dir, { recursive: true })
|
|
114
|
+
const db = new DatabaseSync(join(dir, 'kanban.db'))
|
|
115
|
+
db.exec('PRAGMA journal_mode = WAL')
|
|
116
|
+
db.exec('PRAGMA foreign_keys = ON')
|
|
117
|
+
db.exec(SCHEMA)
|
|
118
|
+
// База могла быть создана прежней версией. Добавляем недостающие столбцы
|
|
119
|
+
// поштучно: ALTER на существующий столбец бросает, и это штатный ответ
|
|
120
|
+
// «уже есть», а не поломка.
|
|
121
|
+
for (const [column, definition] of [
|
|
122
|
+
['waiting', 'INTEGER NOT NULL DEFAULT 0'],
|
|
123
|
+
['syncedAt', 'INTEGER NOT NULL DEFAULT 0'],
|
|
124
|
+
['plan', "TEXT NOT NULL DEFAULT ''"],
|
|
125
|
+
['columnAt', 'INTEGER NOT NULL DEFAULT 0'],
|
|
126
|
+
['archivedAt', 'INTEGER NOT NULL DEFAULT 0'],
|
|
127
|
+
['queuedAt', 'INTEGER NOT NULL DEFAULT 0'],
|
|
128
|
+
['labelColors', "TEXT NOT NULL DEFAULT '{}'"],
|
|
129
|
+
['author', "TEXT NOT NULL DEFAULT ''"],
|
|
130
|
+
['assignee', "TEXT NOT NULL DEFAULT ''"],
|
|
131
|
+
['priority', "TEXT NOT NULL DEFAULT ''"],
|
|
132
|
+
['dueAt', 'INTEGER NOT NULL DEFAULT 0'],
|
|
133
|
+
]) {
|
|
134
|
+
try { db.exec(`ALTER TABLE tasks ADD COLUMN ${column} ${definition}`) } catch { /* столбец уже есть */ }
|
|
135
|
+
}
|
|
136
|
+
try { db.exec("ALTER TABLE boards ADD COLUMN kind TEXT NOT NULL DEFAULT 'project'") } catch { /* уже есть */ }
|
|
137
|
+
// Старые карточки не знают, когда попали в свою колонку. Считаем от
|
|
138
|
+
// заведения: это честнее нуля, который читался бы как «в 1970 году».
|
|
139
|
+
db.exec('UPDATE tasks SET columnAt = createdAt WHERE columnAt = 0')
|
|
140
|
+
|
|
141
|
+
const q = {
|
|
142
|
+
boards: db.prepare('SELECT * FROM boards ORDER BY position'),
|
|
143
|
+
boardById: db.prepare('SELECT * FROM boards WHERE id = ?'),
|
|
144
|
+
insertBoard: db.prepare('INSERT INTO boards (id, title, kind, position, createdAt) VALUES (?, ?, ?, ?, ?)'),
|
|
145
|
+
lastBoard: db.prepare('SELECT position FROM boards ORDER BY position DESC LIMIT 1'),
|
|
146
|
+
taskById: db.prepare('SELECT * FROM tasks WHERE id = ?'),
|
|
147
|
+
allTasks: db.prepare('SELECT * FROM tasks ORDER BY board, col, position'),
|
|
148
|
+
allTransitions: db.prepare('SELECT * FROM transitions ORDER BY id'),
|
|
149
|
+
tasksByBoard: db.prepare('SELECT * FROM tasks WHERE board = ? AND archivedAt = 0 ORDER BY col, position'),
|
|
150
|
+
archived: db.prepare('SELECT * FROM tasks WHERE archivedAt > 0 ORDER BY archivedAt DESC'),
|
|
151
|
+
archivable: db.prepare(
|
|
152
|
+
"SELECT * FROM tasks WHERE archivedAt = 0 AND col = 'done' AND columnAt > 0 AND columnAt <= ?"),
|
|
153
|
+
dismiss: db.prepare('INSERT OR REPLACE INTO dismissed (owner, repo, issueNumber, at) VALUES (?, ?, ?, ?)'),
|
|
154
|
+
isDismissed: db.prepare('SELECT 1 FROM dismissed WHERE owner = ? AND repo = ? AND issueNumber = ?'),
|
|
155
|
+
byIssue: db.prepare('SELECT * FROM tasks WHERE owner = ? AND repo = ? AND issueNumber = ?'),
|
|
156
|
+
tasksBySession: db.prepare('SELECT * FROM tasks WHERE sessionId = ? LIMIT 1'),
|
|
157
|
+
lastInColumn: db.prepare('SELECT position FROM tasks WHERE board = ? AND col = ? ORDER BY position DESC LIMIT 1'),
|
|
158
|
+
deleteTask: db.prepare('DELETE FROM tasks WHERE id = ?'),
|
|
159
|
+
insertTransition: db.prepare('INSERT INTO transitions (taskId, fromCol, toCol, source, detail, at) VALUES (?, ?, ?, ?, ?, ?)'),
|
|
160
|
+
transitionsOf: db.prepare('SELECT * FROM transitions WHERE taskId = ? ORDER BY at, id'),
|
|
161
|
+
countInColumn: db.prepare('SELECT COUNT(*) AS n FROM tasks WHERE board = ? AND col = ?'),
|
|
162
|
+
watchable: db.prepare(
|
|
163
|
+
"SELECT * FROM tasks WHERE issueNumber IS NOT NULL AND col NOT IN ('done') ORDER BY updatedAt DESC"),
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function ensureBoard(id, title, kind) {
|
|
167
|
+
const found = q.boardById.get(id)
|
|
168
|
+
if (found !== undefined) return found
|
|
169
|
+
const last = q.lastBoard.get()
|
|
170
|
+
const position = keyBetween(last?.position, undefined)
|
|
171
|
+
q.insertBoard.run(id, title, normalizeKind(kind), position, Date.now())
|
|
172
|
+
return q.boardById.get(id)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Обе доски заводятся сразу: пустое хранилище без единой доски выглядит в
|
|
176
|
+
// интерфейсе как поломка, а не как чистый старт.
|
|
177
|
+
//
|
|
178
|
+
// Прежняя единственная доска `main` СТАНОВИТСЯ проектной, а не заводится
|
|
179
|
+
// рядом с новой: заведи мы вторую, задачи остались бы на осиротевшей.
|
|
180
|
+
for (const board of DEFAULT_BOARDS) ensureBoard(board.id, board.title, board.kind)
|
|
181
|
+
// Прежняя доска называлась «Основная», и рядом с «Простой» это читается как
|
|
182
|
+
// разница в важности, а не в виде. Переименовываем только нетронутое
|
|
183
|
+
// название: если его меняли, значит оно чьё-то.
|
|
184
|
+
db.prepare("UPDATE boards SET title = ? WHERE id = 'main' AND title = 'Основная доска'")
|
|
185
|
+
.run(DEFAULT_BOARDS[0].title)
|
|
186
|
+
|
|
187
|
+
function positionFor(board, column, beforeId, afterId) {
|
|
188
|
+
const before = beforeId ? q.taskById.get(beforeId) : undefined
|
|
189
|
+
const after = afterId ? q.taskById.get(afterId) : undefined
|
|
190
|
+
if (before === undefined && after === undefined) {
|
|
191
|
+
return keyBetween(q.lastInColumn.get(board, column)?.position, undefined)
|
|
192
|
+
}
|
|
193
|
+
return keyBetween(after?.position, before?.position)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
listBoards() {
|
|
198
|
+
return q.boards.all()
|
|
199
|
+
},
|
|
200
|
+
|
|
201
|
+
createBoard({ id, title, kind }) {
|
|
202
|
+
return ensureBoard(id, title ?? id, kind)
|
|
203
|
+
},
|
|
204
|
+
|
|
205
|
+
/** Вид доски. Неизвестная доска — проектная: пустого набора колонок нет. */
|
|
206
|
+
boardKind(id) {
|
|
207
|
+
return normalizeKind(q.boardById.get(id)?.kind)
|
|
208
|
+
},
|
|
209
|
+
|
|
210
|
+
listTasks({ board = 'main', repo } = {}) {
|
|
211
|
+
const rows = q.tasksByBoard.all(board).map(rowToTask)
|
|
212
|
+
return repo ? rows.filter((t) => t.repo === repo) : rows
|
|
213
|
+
},
|
|
214
|
+
|
|
215
|
+
getTask(id) {
|
|
216
|
+
return rowToTask(q.taskById.get(id))
|
|
217
|
+
},
|
|
218
|
+
|
|
219
|
+
/** Задачи, за которыми стоит issue и по которым работа ещё идёт. */
|
|
220
|
+
listWatchable() {
|
|
221
|
+
return q.watchable.all().map(rowToTask)
|
|
222
|
+
},
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* ВСЕ задачи сессии, в порядке постановки в очередь.
|
|
226
|
+
*
|
|
227
|
+
* Одна сессия ведёт пачку задач, и «чья это сессия» перестало иметь
|
|
228
|
+
* единственный ответ. Инструменты обязаны различать один случай от
|
|
229
|
+
* нескольких, а не брать первую попавшуюся.
|
|
230
|
+
*/
|
|
231
|
+
listTasksBySession(sessionId) {
|
|
232
|
+
if (!sessionId) return []
|
|
233
|
+
return db.prepare(
|
|
234
|
+
'SELECT * FROM tasks WHERE sessionId = ? AND archivedAt = 0 ORDER BY queuedAt, col, position')
|
|
235
|
+
.all(sessionId).map(rowToTask)
|
|
236
|
+
},
|
|
237
|
+
|
|
238
|
+
findTaskBySession(sessionId) {
|
|
239
|
+
if (!sessionId) return undefined
|
|
240
|
+
return rowToTask(q.tasksBySession.get(sessionId))
|
|
241
|
+
},
|
|
242
|
+
|
|
243
|
+
countInColumn(board, column) {
|
|
244
|
+
return q.countInColumn.get(board, column).n
|
|
245
|
+
},
|
|
246
|
+
|
|
247
|
+
createTask(input) {
|
|
248
|
+
const now = Date.now()
|
|
249
|
+
// Задача заведена тогда, когда её завели в Gitea, а не когда подхват
|
|
250
|
+
// довёз карточку. `columnAt` остаётся сегодняшним: в колонку она въехала
|
|
251
|
+
// сейчас, и это разные даты.
|
|
252
|
+
const born = Number(input.createdAt) || now
|
|
253
|
+
const board = input.board ?? 'main'
|
|
254
|
+
const column = input.column ?? 'backlog'
|
|
255
|
+
// Импорт восстанавливает задачу с её прежним id; обычное заведение
|
|
256
|
+
// генерирует новый.
|
|
257
|
+
const id = typeof input.id === 'string' && input.id !== '' ? input.id : randomUUID()
|
|
258
|
+
const position = positionFor(board, column, input.beforeId, input.afterId)
|
|
259
|
+
db.prepare(`INSERT INTO tasks
|
|
260
|
+
(id, board, col, position, title, body, owner, repo, issueNumber, issueUrl,
|
|
261
|
+
labels, branch, worktree, sessionId, model, provider, waiting, syncedAt,
|
|
262
|
+
labelColors, author, assignee, priority, dueAt, columnAt, createdAt, updatedAt)
|
|
263
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
|
|
264
|
+
id, board, column, position,
|
|
265
|
+
input.title ?? '', input.body ?? '',
|
|
266
|
+
input.owner ?? null, input.repo ?? null,
|
|
267
|
+
input.issueNumber ?? null, input.issueUrl ?? null,
|
|
268
|
+
JSON.stringify(input.labels ?? []),
|
|
269
|
+
input.branch ?? null, input.worktree ?? null,
|
|
270
|
+
input.sessionId ?? null, input.model ?? null, input.provider ?? null,
|
|
271
|
+
input.waiting ? 1 : 0,
|
|
272
|
+
Number(input.syncedAt) || 0,
|
|
273
|
+
JSON.stringify(input.labelColors ?? {}),
|
|
274
|
+
input.author ?? '',
|
|
275
|
+
input.assignee ?? '',
|
|
276
|
+
input.priority ?? '',
|
|
277
|
+
Number(input.dueAt) || 0,
|
|
278
|
+
now, born, now,
|
|
279
|
+
)
|
|
280
|
+
return rowToTask(q.taskById.get(id))
|
|
281
|
+
},
|
|
282
|
+
|
|
283
|
+
updateTask(id, patch) {
|
|
284
|
+
const current = q.taskById.get(id)
|
|
285
|
+
if (current === undefined) throw new Error(`задача ${id} не найдена`)
|
|
286
|
+
const fields = []
|
|
287
|
+
const values = []
|
|
288
|
+
for (const key of WRITABLE) {
|
|
289
|
+
if (!Object.hasOwn(patch, key)) continue
|
|
290
|
+
fields.push(`${key} = ?`)
|
|
291
|
+
if (key === 'labels') values.push(JSON.stringify(patch[key] ?? []))
|
|
292
|
+
else if (key === 'labelColors') values.push(JSON.stringify(patch[key] ?? {}))
|
|
293
|
+
else if (key === 'waiting') values.push(patch[key] ? 1 : 0)
|
|
294
|
+
else if (key === 'syncedAt') values.push(Number(patch[key]) || 0)
|
|
295
|
+
else values.push(patch[key] ?? null)
|
|
296
|
+
}
|
|
297
|
+
if (fields.length > 0) {
|
|
298
|
+
values.push(Date.now(), id)
|
|
299
|
+
db.prepare(`UPDATE tasks SET ${fields.join(', ')}, updatedAt = ? WHERE id = ?`).run(...values)
|
|
300
|
+
}
|
|
301
|
+
return rowToTask(q.taskById.get(id))
|
|
302
|
+
},
|
|
303
|
+
|
|
304
|
+
moveTask(id, { column, beforeId, afterId }) {
|
|
305
|
+
const current = q.taskById.get(id)
|
|
306
|
+
if (current === undefined) throw new Error(`задача ${id} не найдена`)
|
|
307
|
+
const target = column ?? current.col
|
|
308
|
+
const position = positionFor(current.board, target, beforeId, afterId)
|
|
309
|
+
const now = Date.now()
|
|
310
|
+
// Время в колонке считаем по отметке, а не по журналу переходов: журнал
|
|
311
|
+
// пришлось бы читать на каждую карточку при каждой сборке доски.
|
|
312
|
+
// Перестановка внутри колонки отметку НЕ сбрасывает — задача в ревью
|
|
313
|
+
// третий день там и остаётся, как бы её ни двигали вверх-вниз.
|
|
314
|
+
const enteredAt = target === current.col ? current.columnAt : now
|
|
315
|
+
// Переезд в работу снимает задачу с очереди: она уже не ждёт, а
|
|
316
|
+
// делается. Иначе номер очереди висел бы у карточки, которая идёт.
|
|
317
|
+
const queuedAt = target === 'in-progress' ? 0 : current.queuedAt
|
|
318
|
+
db.prepare('UPDATE tasks SET col = ?, position = ?, columnAt = ?, queuedAt = ?, updatedAt = ? WHERE id = ?')
|
|
319
|
+
.run(target, position, enteredAt, queuedAt, now, id)
|
|
320
|
+
return rowToTask(q.taskById.get(id))
|
|
321
|
+
},
|
|
322
|
+
|
|
323
|
+
deleteTask(id) {
|
|
324
|
+
// Удалённая карточка задачи из Gitea запоминается как отказанная: иначе
|
|
325
|
+
// подхват вернул бы её на доску следующим же проходом, и доска спорила
|
|
326
|
+
// бы с человеком каждые две минуты.
|
|
327
|
+
const task = q.taskById.get(id)
|
|
328
|
+
if (task?.owner && task?.repo && typeof task.issueNumber === 'number') {
|
|
329
|
+
q.dismiss.run(task.owner, task.repo, task.issueNumber, Date.now())
|
|
330
|
+
}
|
|
331
|
+
q.deleteTask.run(id)
|
|
332
|
+
},
|
|
333
|
+
|
|
334
|
+
/** Отказывались ли уже от этого issue. */
|
|
335
|
+
isDismissed({ owner, repo, issueNumber }) {
|
|
336
|
+
return q.isDismissed.get(owner, repo, issueNumber) !== undefined
|
|
337
|
+
},
|
|
338
|
+
|
|
339
|
+
/** Задача по issue — чтобы подхват не заводил второй раз то же самое. */
|
|
340
|
+
findTaskByIssue({ owner, repo, issueNumber }) {
|
|
341
|
+
return rowToTask(q.byIssue.get(owner, repo, issueNumber))
|
|
342
|
+
},
|
|
343
|
+
|
|
344
|
+
/** Задачи в архиве: свой список, а не колонка. */
|
|
345
|
+
listArchived() {
|
|
346
|
+
return q.archived.all().map(rowToTask)
|
|
347
|
+
},
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Что пора убрать в архив.
|
|
351
|
+
*
|
|
352
|
+
* Возраст считаем по отметке входа в колонку, а не по `updatedAt`: сверка
|
|
353
|
+
* трогает задачу и после завершения, и по `updatedAt` архив откладывался
|
|
354
|
+
* бы бесконечно.
|
|
355
|
+
*/
|
|
356
|
+
listArchivable(before) {
|
|
357
|
+
return q.archivable.all(before).map(rowToTask)
|
|
358
|
+
},
|
|
359
|
+
|
|
360
|
+
setArchived(id, archivedAt) {
|
|
361
|
+
db.prepare('UPDATE tasks SET archivedAt = ?, updatedAt = ? WHERE id = ?')
|
|
362
|
+
.run(archivedAt, Date.now(), id)
|
|
363
|
+
return rowToTask(q.taskById.get(id))
|
|
364
|
+
},
|
|
365
|
+
|
|
366
|
+
addTransition({ taskId, fromCol, toCol, source, detail }) {
|
|
367
|
+
if (!SOURCES.has(source)) throw new Error(`неизвестный источник перехода: ${source}`)
|
|
368
|
+
q.insertTransition.run(taskId, fromCol ?? null, toCol, source, detail ?? '', Date.now())
|
|
369
|
+
},
|
|
370
|
+
|
|
371
|
+
listTransitions(taskId) {
|
|
372
|
+
return q.transitionsOf.all(taskId)
|
|
373
|
+
},
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Полный снимок доски для экспорта: все доски, все задачи (включая
|
|
377
|
+
* архивные) и журнал переходов. Импорт восстанавливает его как есть.
|
|
378
|
+
*/
|
|
379
|
+
exportAll() {
|
|
380
|
+
return {
|
|
381
|
+
version: 1,
|
|
382
|
+
boards: q.boards.all(),
|
|
383
|
+
tasks: q.allTasks.all().map(rowToTask),
|
|
384
|
+
transitions: q.allTransitions.all(),
|
|
385
|
+
}
|
|
386
|
+
},
|
|
387
|
+
|
|
388
|
+
close() {
|
|
389
|
+
db.close()
|
|
390
|
+
},
|
|
391
|
+
}
|
|
392
|
+
}
|