@goodandready/dsh-kanban 0.1.20 → 0.1.22
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 +46 -1
- package/lib/client.js +111 -6
- package/lib/filters.js +307 -288
- package/lib/gitea.js +28 -0
- package/lib/import.js +23 -0
- package/lib/index.js +50 -2
- package/lib/launcher.js +32 -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 +3 -2
package/lib/routes.js
CHANGED
|
@@ -1,331 +1,355 @@
|
|
|
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
|
-
overdue: typeof task.dueAt === 'number' && task.dueAt > 0 && task.dueAt < now,
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/** Разложить задачи: по колонкам, внутри — по проектам, внутри — идущее наверх. */
|
|
77
|
-
function orderForBoard(tasks, columns) {
|
|
78
|
-
const out = []
|
|
79
|
-
for (const column of columns) {
|
|
80
|
-
const inColumn = tasks.filter((t) => t.column === column)
|
|
81
|
-
for (const group of groupByRepo(sortByLife(inColumn))) out.push(...group.tasks)
|
|
82
|
-
}
|
|
83
|
-
// Задачи в колонке, которой на этой доске нет, не теряем: спрятать их совсем
|
|
84
|
-
// хуже, чем показать в непривычном месте.
|
|
85
|
-
const seen = new Set(out.map((t) => t.id))
|
|
86
|
-
for (const task of tasks) if (!seen.has(task.id)) out.push(task)
|
|
87
|
-
return out
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* Ответ доски: колонки в фиксированном порядке, задачи и пределы.
|
|
92
|
-
* Порядок колонок задан кодом, а не настройками: он повторяет воркфлоу, и
|
|
93
|
-
* переставлять его местами незачем.
|
|
94
|
-
*/
|
|
95
|
-
export function buildBoard({ store, config, board = 'main', repo, liveOf, sync, projectRoot }) {
|
|
96
|
-
// Одно «сейчас» на всю сборку: иначе соседние карточки посчитались бы по
|
|
97
|
-
// разным моментам, и разница вылезла бы ровно на границе минуты.
|
|
98
|
-
const now = Date.now()
|
|
99
|
-
// Порядок задаём здесь, а не в браузере: правила группировки и подъёма
|
|
100
|
-
// повторять во второй раз значило бы завести вторую их редакцию.
|
|
101
|
-
//
|
|
102
|
-
// Отдаём задачи УЖЕ разложенными — сперва по проектам, внутри проекта идущая
|
|
103
|
-
// работа наверх. Браузер рисует заголовок при смене проекта, и после отбора
|
|
104
|
-
// группы остаются целыми, потому что отбор порядка не меняет.
|
|
105
|
-
const raw = store.listTasks({ board, repo })
|
|
106
|
-
// Очередь считается внутри своей сессии, а не по доске целиком: две сессии —
|
|
107
|
-
// две независимые очереди.
|
|
108
|
-
const bySession = new Map()
|
|
109
|
-
for (const task of raw) {
|
|
110
|
-
const id = typeof task.sessionId === 'string' ? task.sessionId : ''
|
|
111
|
-
if (id === '') continue
|
|
112
|
-
if (!bySession.has(id)) bySession.set(id, [])
|
|
113
|
-
bySession.get(id).push(task)
|
|
114
|
-
}
|
|
115
|
-
const tasks = orderForBoard(
|
|
116
|
-
raw.map((task) => withPlan(task, liveOf, config, now, bySession.get(task.sessionId) ?? [])),
|
|
117
|
-
columnsOf(store.boardKind(board)),
|
|
118
|
-
)
|
|
119
|
-
const kind = store.boardKind(board)
|
|
120
|
-
// Своё название приходит с сервера, а перевод остаётся на браузере: конфиг
|
|
121
|
-
// живёт здесь, а языки — там, и смешивать их не надо.
|
|
122
|
-
const names = columnNamesOf(config)
|
|
123
|
-
const columns = columnsOf(kind).map((id) => {
|
|
124
|
-
const count = tasks.filter((t) => t.column === id).length
|
|
125
|
-
const limit = wipLimitField(config, id)
|
|
126
|
-
return { id, name: names[id], count, limit, overLimit: limit !== undefined && count > limit }
|
|
127
|
-
})
|
|
128
|
-
// Отборы считаем по ВСЕМ задачам доски, а не по отобранным: счётчик рядом со
|
|
129
|
-
// значением отвечает «сколько там всего», а не «сколько осталось после моего
|
|
130
|
-
// же выбора».
|
|
131
|
-
return {
|
|
132
|
-
board, kind, now, boards: store.listBoards(), columns, tasks,
|
|
133
|
-
// Фактический корень проектов: {path, set} — путь и признак «задан
|
|
134
|
-
// настройкой». Считается на хосте — браузер не знает ни настройку, ни cwd.
|
|
135
|
-
projectRoot: projectRoot ?? { path: '', set: false },
|
|
136
|
-
|
|
137
|
-
facets: facetsOf(tasks),
|
|
138
|
-
// Состояние сверки едет вместе с доской: отдельный опрос ради него был бы
|
|
139
|
-
// вторым источником обращений и вторым местом, где это может отстать.
|
|
140
|
-
sync: sync ?? { state: 'never' },
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/**
|
|
145
|
-
* Перенос карточки. Предел колонки — предупреждение, а не запрет: перенос
|
|
146
|
-
* выполняется, а признак превышения уезжает в ответ, и доска подсветит
|
|
147
|
-
* колонку. Запрет здесь превратил бы предел из ориентира в преграду.
|
|
148
|
-
*/
|
|
149
|
-
export function applyMove({ store, config, id, column, beforeId, afterId, source = 'manual', detail = '' }) {
|
|
150
|
-
const before = store.getTask(id)
|
|
151
|
-
if (before === undefined) return { error: 'task-not-found', status: 404 }
|
|
152
|
-
const task = store.moveTask(id, { column, beforeId, afterId })
|
|
153
|
-
if (task.column !== before.column) {
|
|
154
|
-
store.addTransition({
|
|
155
|
-
taskId: id, fromCol: before.column, toCol: task.column, source, detail,
|
|
156
|
-
})
|
|
157
|
-
}
|
|
158
|
-
const limit = wipLimitField(config, task.column)
|
|
159
|
-
const count = store.countInColumn(task.board, task.column)
|
|
160
|
-
return { task, overLimit: limit !== undefined && count > limit }
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
/** Создание своей задачи (не из Gitea). */
|
|
164
|
-
export function createTask({ store, input }) {
|
|
165
|
-
const title = typeof input?.title === 'string' ? input.title.trim() : ''
|
|
166
|
-
if (title === '') return { error: 'title-required', status: 400 }
|
|
167
|
-
const board = input?.board ?? 'main'
|
|
168
|
-
// Колонка проверяется по ВИДУ доски, а не по общему списку: положить задачу
|
|
169
|
-
// в `deploy` на простой доске значило бы спрятать её в колонке, которой там
|
|
170
|
-
// нет.
|
|
171
|
-
const allowed = columnsOf(store.boardKind(board))
|
|
172
|
-
const column = allowed.includes(input?.column) ? input.column : 'backlog'
|
|
173
|
-
const task = store.createTask({
|
|
174
|
-
board,
|
|
175
|
-
column,
|
|
176
|
-
title,
|
|
177
|
-
body: typeof input?.body === 'string' ? input.body : '',
|
|
178
|
-
labels: Array.isArray(input?.labels) ? input.labels.filter((l) => typeof l === 'string') : [],
|
|
179
|
-
})
|
|
180
|
-
return { task }
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
/** Правка полей карточки. Колонку и порядок правит только перенос. */
|
|
184
|
-
export function updateTask({ store, id, input }) {
|
|
185
|
-
if (store.getTask(id) === undefined) return { error: 'task-not-found', status: 404 }
|
|
186
|
-
const patch = {}
|
|
187
|
-
if (typeof input?.title === 'string' && input.title.trim() !== '') patch.title = input.title.trim()
|
|
188
|
-
if (typeof input?.body === 'string') patch.body = input.body
|
|
189
|
-
if (Array.isArray(input?.labels)) patch.labels = input.labels.filter((l) => typeof l === 'string')
|
|
190
|
-
// Приоритет — локальное свойство доски: в Gitea назад не пишется. Чужие
|
|
191
|
-
// значения отбрасываются: одно испорченное поле не должно ронять правку
|
|
192
|
-
// остальных.
|
|
193
|
-
if (PRIORITIES.includes(input?.priority)) patch.priority = input.priority
|
|
194
|
-
// Дедлайн — тоже локальное свойство. Ноль снимает, положительное число —
|
|
195
|
-
// ставит; отрицательное и мусор отбрасываются.
|
|
196
|
-
if (typeof input?.dueAt === 'number' && Number.isFinite(input.dueAt) && input.dueAt >= 0) {
|
|
197
|
-
patch.dueAt = Math.floor(input.dueAt)
|
|
198
|
-
}
|
|
199
|
-
return { task: store.updateTask(id, patch) }
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
/** Известные приоритеты. Пустой — «без приоритета». */
|
|
203
|
-
export const PRIORITIES = ['', 'high', 'medium', 'low']
|
|
204
|
-
|
|
205
|
-
/**
|
|
206
|
-
* Дописать заметку в тело задачи.
|
|
207
|
-
*
|
|
208
|
-
* Именно в тело, а не в отдельную ленту заметок и не комментарием в Gitea:
|
|
209
|
-
* владелец выбрал самый простой из трёх смыслов, и он же самый дешёвый.
|
|
210
|
-
*
|
|
211
|
-
* Дописываем на сервере, а не в браузере: чип держит копию задачи, и склейка
|
|
212
|
-
* у него потеряла бы всё, что тело нажило с момента её получения.
|
|
213
|
-
*/
|
|
214
|
-
export function appendNote({ store, id, input }) {
|
|
215
|
-
const task = store.getTask(id)
|
|
216
|
-
if (task === undefined) return { error: 'task-not-found', status: 404 }
|
|
217
|
-
const note = typeof input?.text === 'string' ? input.text.trim() : ''
|
|
218
|
-
if (note === '') return { error: 'note-required', status: 400 }
|
|
219
|
-
const body = typeof task.body === 'string' ? task.body : ''
|
|
220
|
-
return { task: store.updateTask(id, { body: body === '' ? note : `${body}\n\n${note}` }) }
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
/**
|
|
224
|
-
* Убрать карточку в архив или вернуть на доску.
|
|
225
|
-
*
|
|
226
|
-
* Возврат ставит её ровно туда, где она была: колонка и порядок сохранены, а
|
|
227
|
-
* `archivedAt` — единственное, что менялось.
|
|
228
|
-
*/
|
|
229
|
-
export function setArchived({ store, id, archived, now = Date.now() }) {
|
|
230
|
-
const task = store.getTask(id)
|
|
231
|
-
if (task === undefined) return { error: 'task-not-found', status: 404 }
|
|
232
|
-
return { task: store.setArchived(id, archived ? now : 0) }
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
/** Архив: свой список, а не колонка — архив стадией работы не является. */
|
|
236
|
-
export function listArchive({ store }) {
|
|
237
|
-
return { tasks: store.listArchived() }
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
return {
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
*
|
|
275
|
-
*
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
return
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
})
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
}
|
|
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
|
+
overdue: typeof task.dueAt === 'number' && task.dueAt > 0 && task.dueAt < now,
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Разложить задачи: по колонкам, внутри — по проектам, внутри — идущее наверх. */
|
|
77
|
+
function orderForBoard(tasks, columns) {
|
|
78
|
+
const out = []
|
|
79
|
+
for (const column of columns) {
|
|
80
|
+
const inColumn = tasks.filter((t) => t.column === column)
|
|
81
|
+
for (const group of groupByRepo(sortByLife(inColumn))) out.push(...group.tasks)
|
|
82
|
+
}
|
|
83
|
+
// Задачи в колонке, которой на этой доске нет, не теряем: спрятать их совсем
|
|
84
|
+
// хуже, чем показать в непривычном месте.
|
|
85
|
+
const seen = new Set(out.map((t) => t.id))
|
|
86
|
+
for (const task of tasks) if (!seen.has(task.id)) out.push(task)
|
|
87
|
+
return out
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Ответ доски: колонки в фиксированном порядке, задачи и пределы.
|
|
92
|
+
* Порядок колонок задан кодом, а не настройками: он повторяет воркфлоу, и
|
|
93
|
+
* переставлять его местами незачем.
|
|
94
|
+
*/
|
|
95
|
+
export function buildBoard({ store, config, board = 'main', repo, liveOf, sync, projectRoot }) {
|
|
96
|
+
// Одно «сейчас» на всю сборку: иначе соседние карточки посчитались бы по
|
|
97
|
+
// разным моментам, и разница вылезла бы ровно на границе минуты.
|
|
98
|
+
const now = Date.now()
|
|
99
|
+
// Порядок задаём здесь, а не в браузере: правила группировки и подъёма
|
|
100
|
+
// повторять во второй раз значило бы завести вторую их редакцию.
|
|
101
|
+
//
|
|
102
|
+
// Отдаём задачи УЖЕ разложенными — сперва по проектам, внутри проекта идущая
|
|
103
|
+
// работа наверх. Браузер рисует заголовок при смене проекта, и после отбора
|
|
104
|
+
// группы остаются целыми, потому что отбор порядка не меняет.
|
|
105
|
+
const raw = store.listTasks({ board, repo })
|
|
106
|
+
// Очередь считается внутри своей сессии, а не по доске целиком: две сессии —
|
|
107
|
+
// две независимые очереди.
|
|
108
|
+
const bySession = new Map()
|
|
109
|
+
for (const task of raw) {
|
|
110
|
+
const id = typeof task.sessionId === 'string' ? task.sessionId : ''
|
|
111
|
+
if (id === '') continue
|
|
112
|
+
if (!bySession.has(id)) bySession.set(id, [])
|
|
113
|
+
bySession.get(id).push(task)
|
|
114
|
+
}
|
|
115
|
+
const tasks = orderForBoard(
|
|
116
|
+
raw.map((task) => withPlan(task, liveOf, config, now, bySession.get(task.sessionId) ?? [])),
|
|
117
|
+
columnsOf(store.boardKind(board)),
|
|
118
|
+
)
|
|
119
|
+
const kind = store.boardKind(board)
|
|
120
|
+
// Своё название приходит с сервера, а перевод остаётся на браузере: конфиг
|
|
121
|
+
// живёт здесь, а языки — там, и смешивать их не надо.
|
|
122
|
+
const names = columnNamesOf(config)
|
|
123
|
+
const columns = columnsOf(kind).map((id) => {
|
|
124
|
+
const count = tasks.filter((t) => t.column === id).length
|
|
125
|
+
const limit = wipLimitField(config, id)
|
|
126
|
+
return { id, name: names[id], count, limit, overLimit: limit !== undefined && count > limit }
|
|
127
|
+
})
|
|
128
|
+
// Отборы считаем по ВСЕМ задачам доски, а не по отобранным: счётчик рядом со
|
|
129
|
+
// значением отвечает «сколько там всего», а не «сколько осталось после моего
|
|
130
|
+
// же выбора».
|
|
131
|
+
return {
|
|
132
|
+
board, kind, now, boards: store.listBoards(), columns, tasks,
|
|
133
|
+
// Фактический корень проектов: {path, set} — путь и признак «задан
|
|
134
|
+
// настройкой». Считается на хосте — браузер не знает ни настройку, ни cwd.
|
|
135
|
+
projectRoot: projectRoot ?? { path: '', set: false },
|
|
136
|
+
|
|
137
|
+
facets: facetsOf(tasks),
|
|
138
|
+
// Состояние сверки едет вместе с доской: отдельный опрос ради него был бы
|
|
139
|
+
// вторым источником обращений и вторым местом, где это может отстать.
|
|
140
|
+
sync: sync ?? { state: 'never' },
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Перенос карточки. Предел колонки — предупреждение, а не запрет: перенос
|
|
146
|
+
* выполняется, а признак превышения уезжает в ответ, и доска подсветит
|
|
147
|
+
* колонку. Запрет здесь превратил бы предел из ориентира в преграду.
|
|
148
|
+
*/
|
|
149
|
+
export function applyMove({ store, config, id, column, beforeId, afterId, source = 'manual', detail = '' }) {
|
|
150
|
+
const before = store.getTask(id)
|
|
151
|
+
if (before === undefined) return { error: 'task-not-found', status: 404 }
|
|
152
|
+
const task = store.moveTask(id, { column, beforeId, afterId })
|
|
153
|
+
if (task.column !== before.column) {
|
|
154
|
+
store.addTransition({
|
|
155
|
+
taskId: id, fromCol: before.column, toCol: task.column, source, detail,
|
|
156
|
+
})
|
|
157
|
+
}
|
|
158
|
+
const limit = wipLimitField(config, task.column)
|
|
159
|
+
const count = store.countInColumn(task.board, task.column)
|
|
160
|
+
return { task, overLimit: limit !== undefined && count > limit }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Создание своей задачи (не из Gitea). */
|
|
164
|
+
export function createTask({ store, input }) {
|
|
165
|
+
const title = typeof input?.title === 'string' ? input.title.trim() : ''
|
|
166
|
+
if (title === '') return { error: 'title-required', status: 400 }
|
|
167
|
+
const board = input?.board ?? 'main'
|
|
168
|
+
// Колонка проверяется по ВИДУ доски, а не по общему списку: положить задачу
|
|
169
|
+
// в `deploy` на простой доске значило бы спрятать её в колонке, которой там
|
|
170
|
+
// нет.
|
|
171
|
+
const allowed = columnsOf(store.boardKind(board))
|
|
172
|
+
const column = allowed.includes(input?.column) ? input.column : 'backlog'
|
|
173
|
+
const task = store.createTask({
|
|
174
|
+
board,
|
|
175
|
+
column,
|
|
176
|
+
title,
|
|
177
|
+
body: typeof input?.body === 'string' ? input.body : '',
|
|
178
|
+
labels: Array.isArray(input?.labels) ? input.labels.filter((l) => typeof l === 'string') : [],
|
|
179
|
+
})
|
|
180
|
+
return { task }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Правка полей карточки. Колонку и порядок правит только перенос. */
|
|
184
|
+
export function updateTask({ store, id, input }) {
|
|
185
|
+
if (store.getTask(id) === undefined) return { error: 'task-not-found', status: 404 }
|
|
186
|
+
const patch = {}
|
|
187
|
+
if (typeof input?.title === 'string' && input.title.trim() !== '') patch.title = input.title.trim()
|
|
188
|
+
if (typeof input?.body === 'string') patch.body = input.body
|
|
189
|
+
if (Array.isArray(input?.labels)) patch.labels = input.labels.filter((l) => typeof l === 'string')
|
|
190
|
+
// Приоритет — локальное свойство доски: в Gitea назад не пишется. Чужие
|
|
191
|
+
// значения отбрасываются: одно испорченное поле не должно ронять правку
|
|
192
|
+
// остальных.
|
|
193
|
+
if (PRIORITIES.includes(input?.priority)) patch.priority = input.priority
|
|
194
|
+
// Дедлайн — тоже локальное свойство. Ноль снимает, положительное число —
|
|
195
|
+
// ставит; отрицательное и мусор отбрасываются.
|
|
196
|
+
if (typeof input?.dueAt === 'number' && Number.isFinite(input.dueAt) && input.dueAt >= 0) {
|
|
197
|
+
patch.dueAt = Math.floor(input.dueAt)
|
|
198
|
+
}
|
|
199
|
+
return { task: store.updateTask(id, patch) }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Известные приоритеты. Пустой — «без приоритета». */
|
|
203
|
+
export const PRIORITIES = ['', 'high', 'medium', 'low']
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Дописать заметку в тело задачи.
|
|
207
|
+
*
|
|
208
|
+
* Именно в тело, а не в отдельную ленту заметок и не комментарием в Gitea:
|
|
209
|
+
* владелец выбрал самый простой из трёх смыслов, и он же самый дешёвый.
|
|
210
|
+
*
|
|
211
|
+
* Дописываем на сервере, а не в браузере: чип держит копию задачи, и склейка
|
|
212
|
+
* у него потеряла бы всё, что тело нажило с момента её получения.
|
|
213
|
+
*/
|
|
214
|
+
export function appendNote({ store, id, input }) {
|
|
215
|
+
const task = store.getTask(id)
|
|
216
|
+
if (task === undefined) return { error: 'task-not-found', status: 404 }
|
|
217
|
+
const note = typeof input?.text === 'string' ? input.text.trim() : ''
|
|
218
|
+
if (note === '') return { error: 'note-required', status: 400 }
|
|
219
|
+
const body = typeof task.body === 'string' ? task.body : ''
|
|
220
|
+
return { task: store.updateTask(id, { body: body === '' ? note : `${body}\n\n${note}` }) }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Убрать карточку в архив или вернуть на доску.
|
|
225
|
+
*
|
|
226
|
+
* Возврат ставит её ровно туда, где она была: колонка и порядок сохранены, а
|
|
227
|
+
* `archivedAt` — единственное, что менялось.
|
|
228
|
+
*/
|
|
229
|
+
export function setArchived({ store, id, archived, now = Date.now() }) {
|
|
230
|
+
const task = store.getTask(id)
|
|
231
|
+
if (task === undefined) return { error: 'task-not-found', status: 404 }
|
|
232
|
+
return { task: store.setArchived(id, archived ? now : 0) }
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Архив: свой список, а не колонка — архив стадией работы не является. */
|
|
236
|
+
export function listArchive({ store }) {
|
|
237
|
+
return { tasks: store.listArchived() }
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Назначить ответственного или снять назначение.
|
|
242
|
+
*
|
|
243
|
+
* Пустой логин — это снятие, а не отказ: «никто» такое же законное состояние
|
|
244
|
+
* задачи, как и любой исполнитель.
|
|
245
|
+
*
|
|
246
|
+
* @returns {{task: object}|{error: string, status: number}}
|
|
247
|
+
*/
|
|
248
|
+
export function assignTask({ store, id, login }) {
|
|
249
|
+
const task = store.getTask(id)
|
|
250
|
+
if (task === undefined) return { error: 'task-not-found', status: 404 }
|
|
251
|
+
const who = typeof login === 'string' ? login.trim() : ''
|
|
252
|
+
if (who === (task.assignee ?? '')) return { task }
|
|
253
|
+
store.updateTask(id, { assignee: who })
|
|
254
|
+
store.addTransition({
|
|
255
|
+
taskId: id,
|
|
256
|
+
fromCol: task.column,
|
|
257
|
+
toCol: task.column,
|
|
258
|
+
source: 'manual',
|
|
259
|
+
detail: who === '' ? 'ответственный снят' : `ответственный: ${who}`,
|
|
260
|
+
})
|
|
261
|
+
return { task: store.getTask(id) }
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export function deleteTask({ store, id }) {
|
|
265
|
+
if (store.getTask(id) === undefined) return { error: 'task-not-found', status: 404 }
|
|
266
|
+
store.deleteTask(id)
|
|
267
|
+
return { ok: true }
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Тело задачи, разобранное в дерево блоков.
|
|
272
|
+
*
|
|
273
|
+
* Разбираем здесь, а не в браузере: браузерная половина — отдельный бандл и
|
|
274
|
+
* позвать `lib/markdown.js` не может. Заодно тело не едет в разметку строкой,
|
|
275
|
+
* и подставить туда чужое нечем.
|
|
276
|
+
*/
|
|
277
|
+
export function taskBody({ store, id }) {
|
|
278
|
+
const task = store.getTask(id)
|
|
279
|
+
if (task === undefined) return { error: 'task-not-found', status: 404 }
|
|
280
|
+
return { blocks: parseBody(task.body) }
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export function taskLog({ store, id }) {
|
|
284
|
+
if (store.getTask(id) === undefined) return { error: 'task-not-found', status: 404 }
|
|
285
|
+
return { transitions: store.listTransitions(id) }
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Задача, привязанная к сессии; отсутствие задачи — штатный ответ. */
|
|
289
|
+
export function taskBySession({ store, sessionId }) {
|
|
290
|
+
const task = store.findTaskBySession(sessionId)
|
|
291
|
+
if (task === undefined) return { task: null }
|
|
292
|
+
// Вид доски едет вместе с задачей: чип предлагает колонки, а предлагать
|
|
293
|
+
// `deploy` задаче с простой доски значит звать в колонку, которой нет.
|
|
294
|
+
return { task, columns: columnsOf(store.boardKind(task.board)) }
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Полный снимок доски для бэкапа/переноса.
|
|
299
|
+
*
|
|
300
|
+
* Отдаём всё, что есть в хранилище: доски, задачи (включая архивные) и журнал
|
|
301
|
+
* переходов. Импорт восстанавливает это как есть.
|
|
302
|
+
*/
|
|
303
|
+
export function exportBoard({ store }) {
|
|
304
|
+
return store.exportAll()
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Восстановить доску из снимка.
|
|
309
|
+
*
|
|
310
|
+
* Идемпотентно: существующая задача с тем же id не перезаписывается, а
|
|
311
|
+
* пропускается — повторный импорт не плодит дубли. Доски создаются, если их
|
|
312
|
+
* ещё нет.
|
|
313
|
+
*
|
|
314
|
+
* @returns {{imported: number, skipped: number, error?: string}}
|
|
315
|
+
*/
|
|
316
|
+
export function importBoard({ store, input }) {
|
|
317
|
+
if (input === null || typeof input !== 'object') return { error: 'bad-import', status: 400 }
|
|
318
|
+
if (input.version !== 1) return { error: 'bad-import-version', status: 400 }
|
|
319
|
+
if (!Array.isArray(input.tasks)) return { error: 'bad-import', status: 400 }
|
|
320
|
+
|
|
321
|
+
let imported = 0
|
|
322
|
+
let skipped = 0
|
|
323
|
+
|
|
324
|
+
for (const board of Array.isArray(input.boards) ? input.boards : []) {
|
|
325
|
+
if (typeof board?.id !== 'string' || board.id === '') continue
|
|
326
|
+
store.createBoard({ id: board.id, title: board.title, kind: board.kind })
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
for (const task of input.tasks) {
|
|
330
|
+
if (typeof task?.id !== 'string' || task.id === '') continue
|
|
331
|
+
if (store.getTask(task.id) !== undefined) { skipped += 1; continue }
|
|
332
|
+
store.createTask({
|
|
333
|
+
id: task.id,
|
|
334
|
+
board: task.board ?? 'main',
|
|
335
|
+
column: task.column ?? 'backlog',
|
|
336
|
+
title: task.title ?? '',
|
|
337
|
+
body: task.body ?? '',
|
|
338
|
+
owner: task.owner, repo: task.repo,
|
|
339
|
+
issueNumber: task.issueNumber, issueUrl: task.issueUrl,
|
|
340
|
+
labels: Array.isArray(task.labels) ? task.labels : [],
|
|
341
|
+
branch: task.branch, worktree: task.worktree,
|
|
342
|
+
sessionId: task.sessionId, model: task.model, provider: task.provider,
|
|
343
|
+
waiting: task.waiting === true,
|
|
344
|
+
plan: task.plan ?? '',
|
|
345
|
+
labelColors: task.labelColors ?? {},
|
|
346
|
+
author: task.author ?? '',
|
|
347
|
+
priority: task.priority ?? '',
|
|
348
|
+
dueAt: typeof task.dueAt === 'number' ? task.dueAt : 0,
|
|
349
|
+
createdAt: task.createdAt,
|
|
350
|
+
})
|
|
351
|
+
imported += 1
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
return { imported, skipped }
|
|
355
|
+
}
|