@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/board-tool.js +18 -48
- package/lib/client.js +85 -176
- package/lib/commands.js +21 -38
- package/lib/config.js +30 -55
- package/lib/cron.js +13 -13
- package/lib/events.js +17 -5
- package/lib/filters.js +58 -82
- package/lib/git-provider.js +23 -23
- package/lib/gitea.js +59 -110
- package/lib/github.js +39 -37
- package/lib/import.js +46 -67
- package/lib/index.js +131 -140
- package/lib/intake.js +18 -26
- package/lib/launcher.js +107 -170
- package/lib/lifecycle.js +11 -17
- package/lib/markdown.js +16 -23
- package/lib/metrics.js +23 -25
- package/lib/order.js +25 -28
- package/lib/outbound.js +28 -28
- package/lib/plan.js +23 -45
- package/lib/power.js +2 -2
- package/lib/progress-dump.js +17 -17
- package/lib/routes.js +92 -145
- package/lib/store.js +68 -77
- package/lib/sync.js +56 -98
- package/lib/time.js +8 -17
- package/lib/transitions.js +44 -61
- package/lib/webhook.js +16 -23
- package/lib/workflow-bridge.js +72 -68
- package/lib/worktree.js +103 -60
- package/package.json +1 -1
package/lib/routes.js
CHANGED
|
@@ -1,12 +1,9 @@
|
|
|
1
|
-
//
|
|
1
|
+
// HTTP route handlers for the board.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// Перехвата чужих маршрутов и промежуточных обработчиков в DSH нет:
|
|
9
|
-
// `ctx.webServer.register` — единственный способ.
|
|
3
|
+
// The client bundle runs in the browser without direct access to SQLite or host services;
|
|
4
|
+
// it interacts with these handlers. Handlers accept parsed inputs and the store instance,
|
|
5
|
+
// returning response objects to facilitate testing without full HTTP server setups.
|
|
6
|
+
// Request/response wrapper integration lives in `lib/index.js`.
|
|
10
7
|
|
|
11
8
|
import { COLUMN_ORDER, columnsOf, wipLimitField, columnNamesOf } from './config.js'
|
|
12
9
|
import { parsePlan, planItems, planProgress, taskState, queuePosition } from './plan.js'
|
|
@@ -16,7 +13,7 @@ import { facetsOf, facetsOfTask, groupByRepo, sortByLife } from './filters.js'
|
|
|
16
13
|
import { parseBody } from './markdown.js'
|
|
17
14
|
import { computeNextRun } from './cron.js'
|
|
18
15
|
|
|
19
|
-
/**
|
|
16
|
+
/** Maximum request payload size to prevent denial of service. */
|
|
20
17
|
export const MAX_BODY_BYTES = 256 * 1024
|
|
21
18
|
|
|
22
19
|
function isLoopbackAddress(addr) {
|
|
@@ -28,17 +25,16 @@ function isLoopbackAddress(addr) {
|
|
|
28
25
|
}
|
|
29
26
|
|
|
30
27
|
/**
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
* 3. Loopback-источник (remoteAddress: 127.0.0.1, ::1, ::ffff:127.0.0.1).
|
|
28
|
+
* Cross-site request validation (#261).
|
|
29
|
+
* Mutating endpoints modify local state and reject cross-site requests:
|
|
30
|
+
* 1. Safe sec-fetch-site ('same-origin', 'same-site', 'none').
|
|
31
|
+
* 2. Origin matches host header (when origin is present).
|
|
32
|
+
* 3. Loopback remoteAddress (127.0.0.1, ::1, ::ffff:127.0.0.1).
|
|
37
33
|
*/
|
|
38
34
|
export function isTrustedRequest(req) {
|
|
39
35
|
if (!req || !req.headers) return false
|
|
40
36
|
|
|
41
|
-
// 1.
|
|
37
|
+
// 1. Verify sec-fetch-site header (missing or cross-site rejected)
|
|
42
38
|
const site = req.headers['sec-fetch-site']
|
|
43
39
|
if (!site || site === 'cross-site') {
|
|
44
40
|
return false
|
|
@@ -47,7 +43,7 @@ export function isTrustedRequest(req) {
|
|
|
47
43
|
return false
|
|
48
44
|
}
|
|
49
45
|
|
|
50
|
-
// 2.
|
|
46
|
+
// 2. Validate origin against host
|
|
51
47
|
const origin = req.headers.origin
|
|
52
48
|
if (origin !== undefined) {
|
|
53
49
|
if (origin === 'null') return false
|
|
@@ -60,7 +56,7 @@ export function isTrustedRequest(req) {
|
|
|
60
56
|
}
|
|
61
57
|
}
|
|
62
58
|
|
|
63
|
-
// 3.
|
|
59
|
+
// 3. Verify loopback remoteAddress
|
|
64
60
|
const remote = req.socket?.remoteAddress || req.connection?.remoteAddress
|
|
65
61
|
if (remote && !isLoopbackAddress(remote)) {
|
|
66
62
|
return false
|
|
@@ -69,7 +65,7 @@ export function isTrustedRequest(req) {
|
|
|
69
65
|
return true
|
|
70
66
|
}
|
|
71
67
|
|
|
72
|
-
/**
|
|
68
|
+
/** Parse `/dsh-kanban/task/<id>[/<action>]`; returns undefined if non-matching. */
|
|
73
69
|
export function parseTaskPath(pathname) {
|
|
74
70
|
const m = /^\/dsh-kanban\/task\/([^/]+)(?:\/([a-z-]+))?\/?$/.exec(pathname ?? '')
|
|
75
71
|
if (m === null) return undefined
|
|
@@ -77,19 +73,14 @@ export function parseTaskPath(pathname) {
|
|
|
77
73
|
}
|
|
78
74
|
|
|
79
75
|
/**
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
* Сырую строку плана наружу не отдаём: браузерная половина — отдельный бандл,
|
|
83
|
-
* она не может позвать `lib/plan.js`, и разбор пришлось бы писать второй раз.
|
|
76
|
+
* Enrich task with parsed plan, state, and filters.
|
|
84
77
|
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
* отсутствии агента, а не догадка по тишине.
|
|
78
|
+
* Plan is pre-parsed on server because client bundle cannot import `lib/plan.js`.
|
|
79
|
+
* `liveOf` returns active agent state or undefined if session is inactive.
|
|
88
80
|
*/
|
|
89
81
|
function withPlan(task, liveOf, config, now, sameSession) {
|
|
90
82
|
const plan = parsePlan(task.plan)
|
|
91
|
-
//
|
|
92
|
-
// непрочитанная сессия не должна прятать от человека все задачи.
|
|
83
|
+
// Registry error implies agent unavailable; do not fail whole board render.
|
|
93
84
|
let live
|
|
94
85
|
try {
|
|
95
86
|
if (typeof liveOf === 'function' && task.sessionId) live = liveOf(task.sessionId)
|
|
@@ -98,57 +89,44 @@ function withPlan(task, liveOf, config, now, sameSession) {
|
|
|
98
89
|
...task,
|
|
99
90
|
plan: { items: planItems(plan), progress: planProgress(plan) },
|
|
100
91
|
state: taskState({ task, live }),
|
|
101
|
-
//
|
|
102
|
-
// у оставшихся третий сам становится вторым.
|
|
92
|
+
// Queue position calculated dynamically across active tasks in same session.
|
|
103
93
|
queuePos: queuePosition(task, sameSession),
|
|
104
|
-
//
|
|
94
|
+
// Label facets extracted on server for consistent client filtering.
|
|
105
95
|
facets: facetsOfTask(task),
|
|
106
|
-
//
|
|
107
|
-
// на хосте. Отдавать браузеру ещё и порог значило бы отдавать ему решение.
|
|
96
|
+
// Stale calculation performed on host where configuration resides.
|
|
108
97
|
stale: isStale({
|
|
109
98
|
now,
|
|
110
99
|
updatedAt: task.updatedAt,
|
|
111
100
|
sessionId: task.sessionId,
|
|
112
101
|
afterMinutes: config?.staleAfterMin,
|
|
113
102
|
}),
|
|
114
|
-
//
|
|
115
|
-
// чтобы браузер не зависел от своих часов.
|
|
103
|
+
// Overdue calculated on host using server clock.
|
|
116
104
|
overdue: typeof task.dueAt === 'number' && task.dueAt > 0 && task.dueAt < now,
|
|
117
105
|
}
|
|
118
106
|
}
|
|
119
107
|
|
|
120
|
-
/**
|
|
108
|
+
/** Order tasks: by column, grouped by repository, with active tasks on top. */
|
|
121
109
|
function orderForBoard(tasks, columns) {
|
|
122
110
|
const out = []
|
|
123
111
|
for (const column of columns) {
|
|
124
112
|
const inColumn = tasks.filter((t) => t.column === column)
|
|
125
113
|
for (const group of groupByRepo(sortByLife(inColumn))) out.push(...group.tasks)
|
|
126
114
|
}
|
|
127
|
-
//
|
|
128
|
-
// хуже, чем показать в непривычном месте.
|
|
115
|
+
// Retain tasks in columns not visible on current board to prevent data loss.
|
|
129
116
|
const seen = new Set(out.map((t) => t.id))
|
|
130
117
|
for (const task of tasks) if (!seen.has(task.id)) out.push(task)
|
|
131
118
|
return out
|
|
132
119
|
}
|
|
133
120
|
|
|
134
121
|
/**
|
|
135
|
-
*
|
|
136
|
-
* Порядок колонок задан кодом, а не настройками: он повторяет воркфлоу, и
|
|
137
|
-
* переставлять его местами незачем.
|
|
122
|
+
* Build board response payload: columns in canonical order, tasks, and limits.
|
|
138
123
|
*/
|
|
139
124
|
export function buildBoard({ store, config, board = 'main', repo, liveOf, sync, projectRoot }) {
|
|
140
|
-
//
|
|
141
|
-
// разным моментам, и разница вылезла бы ровно на границе минуты.
|
|
125
|
+
// Uniform timestamp across snapshot calculation.
|
|
142
126
|
const now = Date.now()
|
|
143
|
-
//
|
|
144
|
-
// повторять во второй раз значило бы завести вторую их редакцию.
|
|
145
|
-
//
|
|
146
|
-
// Отдаём задачи УЖЕ разложенными — сперва по проектам, внутри проекта идущая
|
|
147
|
-
// работа наверх. Браузер рисует заголовок при смене проекта, и после отбора
|
|
148
|
-
// группы остаются целыми, потому что отбор порядка не меняет.
|
|
127
|
+
// Tasks are sorted and grouped on server: repo grouping with active items first.
|
|
149
128
|
const raw = store.listTasks({ board, repo })
|
|
150
|
-
//
|
|
151
|
-
// две независимые очереди.
|
|
129
|
+
// Queue position scoped per session.
|
|
152
130
|
const bySession = new Map()
|
|
153
131
|
for (const task of raw) {
|
|
154
132
|
const id = typeof task.sessionId === 'string' ? task.sessionId : ''
|
|
@@ -167,38 +145,38 @@ export function buildBoard({ store, config, board = 'main', repo, liveOf, sync,
|
|
|
167
145
|
columnsOf(store.boardKind(board)),
|
|
168
146
|
)
|
|
169
147
|
const kind = store.boardKind(board)
|
|
170
|
-
//
|
|
171
|
-
// живёт здесь, а языки — там, и смешивать их не надо.
|
|
148
|
+
// Column custom names sent from server; locale translation handled by client.
|
|
172
149
|
const names = columnNamesOf(config)
|
|
173
150
|
const columns = columnsOf(kind).map((id) => {
|
|
174
151
|
const count = tasks.filter((t) => t.column === id).length
|
|
175
152
|
const limit = wipLimitField(config, id)
|
|
176
153
|
return { id, name: names[id], count, limit, overLimit: limit !== undefined && count > limit }
|
|
177
154
|
})
|
|
178
|
-
//
|
|
179
|
-
// значением отвечает «сколько там всего», а не «сколько осталось после моего
|
|
180
|
-
// же выбора».
|
|
155
|
+
// Filter counts calculated over all tasks on board.
|
|
181
156
|
return {
|
|
182
157
|
board, kind, now, boards: store.listBoards(), columns, tasks,
|
|
183
|
-
//
|
|
184
|
-
// настройкой». Считается на хосте — браузер не знает ни настройку, ни cwd.
|
|
158
|
+
// Resolved project root {path, set} computed on host.
|
|
185
159
|
projectRoot: projectRoot ?? { path: '', set: false },
|
|
186
160
|
|
|
187
161
|
facets: facetsOf(tasks),
|
|
188
|
-
//
|
|
189
|
-
// вторым источником обращений и вторым местом, где это может отстать.
|
|
162
|
+
// Sync telemetry state attached directly to board snapshot.
|
|
190
163
|
sync: sync ?? { state: 'never' },
|
|
191
164
|
}
|
|
192
165
|
}
|
|
193
166
|
|
|
194
167
|
/**
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
* колонку. Запрет здесь превратил бы предел из ориентира в преграду.
|
|
168
|
+
* Card move transition. WIP limit is advisory: move executes, and exceeded status
|
|
169
|
+
* is returned for UI highlighting.
|
|
198
170
|
*/
|
|
199
171
|
export function applyMove({ store, config, id, column, beforeId, afterId, source = 'manual', detail = '' }) {
|
|
200
172
|
const before = store.getTask(id)
|
|
201
173
|
if (before === undefined) return { error: 'task-not-found', status: 404 }
|
|
174
|
+
if (column !== undefined && column !== null) {
|
|
175
|
+
const allowed = columnsOf(store.boardKind(before.board))
|
|
176
|
+
if (!allowed.includes(column)) {
|
|
177
|
+
return { error: 'invalid-column', status: 400 }
|
|
178
|
+
}
|
|
179
|
+
}
|
|
202
180
|
const task = store.moveTask(id, { column, beforeId, afterId })
|
|
203
181
|
if (task.column !== before.column) {
|
|
204
182
|
store.addTransition({
|
|
@@ -210,14 +188,12 @@ export function applyMove({ store, config, id, column, beforeId, afterId, source
|
|
|
210
188
|
return { task, overLimit: limit !== undefined && count > limit }
|
|
211
189
|
}
|
|
212
190
|
|
|
213
|
-
/**
|
|
191
|
+
/** Create local board task (not linked to Gitea). */
|
|
214
192
|
export function createTask({ store, input }) {
|
|
215
193
|
const title = typeof input?.title === 'string' ? input.title.trim() : ''
|
|
216
194
|
if (title === '') return { error: 'title-required', status: 400 }
|
|
217
195
|
const board = input?.board ?? 'main'
|
|
218
|
-
//
|
|
219
|
-
// в `deploy` на простой доске значило бы спрятать её в колонке, которой там
|
|
220
|
-
// нет.
|
|
196
|
+
// Validate column against board kind; simple boards do not have deploy/cleanup.
|
|
221
197
|
const allowed = columnsOf(store.boardKind(board))
|
|
222
198
|
const column = allowed.includes(input?.column) ? input.column : 'backlog'
|
|
223
199
|
const task = store.createTask({
|
|
@@ -230,7 +206,7 @@ export function createTask({ store, input }) {
|
|
|
230
206
|
return { task }
|
|
231
207
|
}
|
|
232
208
|
|
|
233
|
-
/**
|
|
209
|
+
/** Update card fields. Column transitions are handled via applyMove. */
|
|
234
210
|
export function updateTask({ store, id, input }) {
|
|
235
211
|
const current = store.getTask(id)
|
|
236
212
|
if (current === undefined) return { error: 'task-not-found', status: 404 }
|
|
@@ -238,12 +214,9 @@ export function updateTask({ store, id, input }) {
|
|
|
238
214
|
if (typeof input?.title === 'string' && input.title.trim() !== '') patch.title = input.title.trim()
|
|
239
215
|
if (typeof input?.body === 'string') patch.body = input.body
|
|
240
216
|
if (Array.isArray(input?.labels)) patch.labels = input.labels.filter((l) => typeof l === 'string')
|
|
241
|
-
//
|
|
242
|
-
// значения отбрасываются: одно испорченное поле не должно ронять правку
|
|
243
|
-
// остальных.
|
|
217
|
+
// Priority is local to board and not synced back to Gitea.
|
|
244
218
|
if (PRIORITIES.includes(input?.priority)) patch.priority = input.priority
|
|
245
|
-
//
|
|
246
|
-
// ставит; отрицательное и мусор отбрасываются.
|
|
219
|
+
// Deadline is local. 0 clears deadline; positive value sets it.
|
|
247
220
|
if (typeof input?.dueAt === 'number' && Number.isFinite(input.dueAt) && input.dueAt >= 0) {
|
|
248
221
|
patch.dueAt = Math.floor(input.dueAt)
|
|
249
222
|
}
|
|
@@ -273,17 +246,12 @@ export function confirmPermission({ store, id }) {
|
|
|
273
246
|
return { task: store.updateTask(id, { permissionConfirmedAt: Date.now() }) }
|
|
274
247
|
}
|
|
275
248
|
|
|
276
|
-
/**
|
|
249
|
+
/** Known priority values. Empty string indicates unprioritized. */
|
|
277
250
|
const PRIORITIES = ['', 'high', 'medium', 'low']
|
|
278
251
|
|
|
279
252
|
/**
|
|
280
|
-
*
|
|
281
|
-
*
|
|
282
|
-
* Именно в тело, а не в отдельную ленту заметок и не комментарием в Gitea:
|
|
283
|
-
* владелец выбрал самый простой из трёх смыслов, и он же самый дешёвый.
|
|
284
|
-
*
|
|
285
|
-
* Дописываем на сервере, а не в браузере: чип держит копию задачи, и склейка
|
|
286
|
-
* у него потеряла бы всё, что тело нажило с момента её получения.
|
|
253
|
+
* Append note to task body.
|
|
254
|
+
* Handled on server to prevent overwriting edits made elsewhere.
|
|
287
255
|
*/
|
|
288
256
|
export function appendNote({ store, id, input }) {
|
|
289
257
|
const task = store.getTask(id)
|
|
@@ -295,10 +263,8 @@ export function appendNote({ store, id, input }) {
|
|
|
295
263
|
}
|
|
296
264
|
|
|
297
265
|
/**
|
|
298
|
-
*
|
|
299
|
-
*
|
|
300
|
-
* Возврат ставит её ровно туда, где она была: колонка и порядок сохранены, а
|
|
301
|
-
* `archivedAt` — единственное, что менялось.
|
|
266
|
+
* Archive or unarchive card.
|
|
267
|
+
* Unarchiving restores card to its original column and position.
|
|
302
268
|
*/
|
|
303
269
|
export function setArchived({ store, id, archived, now = Date.now() }) {
|
|
304
270
|
const task = store.getTask(id)
|
|
@@ -306,16 +272,14 @@ export function setArchived({ store, id, archived, now = Date.now() }) {
|
|
|
306
272
|
return { task: store.setArchived(id, archived ? now : 0) }
|
|
307
273
|
}
|
|
308
274
|
|
|
309
|
-
/**
|
|
275
|
+
/** Query archived tasks list. */
|
|
310
276
|
export function listArchive({ store }) {
|
|
311
277
|
return { tasks: store.listArchived() }
|
|
312
278
|
}
|
|
313
279
|
|
|
314
280
|
/**
|
|
315
|
-
*
|
|
316
|
-
*
|
|
317
|
-
* Пустой логин — это снятие, а не отказ: «никто» такое же законное состояние
|
|
318
|
-
* задачи, как и любой исполнитель.
|
|
281
|
+
* Assign task or clear assignment.
|
|
282
|
+
* Empty login unassigns the task.
|
|
319
283
|
*
|
|
320
284
|
* @returns {{task: object}|{error: string, status: number}}
|
|
321
285
|
*/
|
|
@@ -330,7 +294,7 @@ export function assignTask({ store, id, login }) {
|
|
|
330
294
|
fromCol: task.column,
|
|
331
295
|
toCol: task.column,
|
|
332
296
|
source: 'manual',
|
|
333
|
-
detail: who === '' ? '
|
|
297
|
+
detail: who === '' ? 'unassigned' : `assigned: ${who}`,
|
|
334
298
|
})
|
|
335
299
|
return { task: store.getTask(id) }
|
|
336
300
|
}
|
|
@@ -342,18 +306,11 @@ export function deleteTask({ store, id }) {
|
|
|
342
306
|
}
|
|
343
307
|
|
|
344
308
|
/**
|
|
345
|
-
*
|
|
346
|
-
*
|
|
347
|
-
* Разбираем здесь, а не в браузере: браузерная половина — отдельный бандл и
|
|
348
|
-
* позвать `lib/markdown.js` не может. Заодно тело не едет в разметку строкой,
|
|
349
|
-
* и подставить туда чужое нечем.
|
|
309
|
+
* Parse task markdown body into structured block elements.
|
|
310
|
+
* Done on server so client bundle does not need markdown parser.
|
|
350
311
|
*/
|
|
351
312
|
/**
|
|
352
|
-
*
|
|
353
|
-
*
|
|
354
|
-
* Считается по журналу переходов и ничего не записывает. Порог «залежалось»
|
|
355
|
-
* приходит запросом, а не настройкой: это вопрос, который задают по-разному в
|
|
356
|
-
* разные дни, а не свойство доски.
|
|
313
|
+
* Board metrics: WIP distribution and stale tasks.
|
|
357
314
|
*
|
|
358
315
|
* @returns {{columns: object[], done: object, stale: object[], tasks: number}}
|
|
359
316
|
*/
|
|
@@ -380,10 +337,8 @@ export function boardMetrics({ store, board = 'main', now = Date.now(), staleDay
|
|
|
380
337
|
}
|
|
381
338
|
|
|
382
339
|
/**
|
|
383
|
-
*
|
|
384
|
-
*
|
|
385
|
-
* Только когда сессия у задачи записана, а живого агента за ней нет: у живой
|
|
386
|
-
* продолжать нечего, у задачи без сессии — нечего возобновлять.
|
|
340
|
+
* Check whether a task can be resumed.
|
|
341
|
+
* Eligible when task has recorded sessionId but no active agent is running.
|
|
387
342
|
*/
|
|
388
343
|
export function canRevive(task, live) {
|
|
389
344
|
const has = typeof task?.sessionId === 'string' && task.sessionId !== ''
|
|
@@ -401,14 +356,13 @@ export function taskLog({ store, id }) {
|
|
|
401
356
|
return { transitions: store.listTransitions(id) }
|
|
402
357
|
}
|
|
403
358
|
|
|
404
|
-
/**
|
|
359
|
+
/** Find task associated with session; absence is normal response. */
|
|
405
360
|
export function taskBySession({ store, sessionId, agents }) {
|
|
406
361
|
const task = store.findTaskBySession(sessionId)
|
|
407
362
|
if (task === undefined) return { task: null }
|
|
408
363
|
let live
|
|
409
364
|
try { live = agents?.get?.(sessionId) } catch { live = undefined }
|
|
410
|
-
//
|
|
411
|
-
// `deploy` задаче с простой доски значит звать в колонку, которой нет.
|
|
365
|
+
// Board kind returned with task so UI displays matching column choices.
|
|
412
366
|
return {
|
|
413
367
|
task,
|
|
414
368
|
columns: columnsOf(store.boardKind(task.board)),
|
|
@@ -417,21 +371,14 @@ export function taskBySession({ store, sessionId, agents }) {
|
|
|
417
371
|
}
|
|
418
372
|
|
|
419
373
|
/**
|
|
420
|
-
*
|
|
421
|
-
*
|
|
422
|
-
* Отдаём всё, что есть в хранилище: доски, задачи (включая архивные) и журнал
|
|
423
|
-
* переходов. Импорт восстанавливает это как есть.
|
|
374
|
+
* Export full board snapshot for backup or migration.
|
|
424
375
|
*/
|
|
425
376
|
export function exportBoard({ store }) {
|
|
426
377
|
return store.exportAll()
|
|
427
378
|
}
|
|
428
379
|
|
|
429
380
|
/**
|
|
430
|
-
*
|
|
431
|
-
*
|
|
432
|
-
* Идемпотентно: существующая задача с тем же id не перезаписывается, а
|
|
433
|
-
* пропускается — повторный импорт не плодит дубли. Доски создаются, если их
|
|
434
|
-
* ещё нет.
|
|
381
|
+
* Restore board from backup snapshot (idempotent; skips existing IDs).
|
|
435
382
|
*
|
|
436
383
|
* @returns {{imported: number, skipped: number, error?: string}}
|
|
437
384
|
*/
|
|
@@ -491,28 +438,28 @@ export function importBoard({ store, input }) {
|
|
|
491
438
|
}
|
|
492
439
|
|
|
493
440
|
/**
|
|
494
|
-
*
|
|
441
|
+
* Export tasks to Excel-compatible CSV with UTF-8 BOM (#223).
|
|
495
442
|
*
|
|
496
443
|
* @param {{store: object, board?: string}} options
|
|
497
|
-
* @returns {string} CSV
|
|
444
|
+
* @returns {string} CSV string with UTF-8 BOM prefix
|
|
498
445
|
*/
|
|
499
446
|
export function exportTasksCsv({ store, board = 'main' }) {
|
|
500
447
|
const tasks = store.listTasks({ board })
|
|
501
448
|
const headers = [
|
|
502
449
|
'ID',
|
|
503
|
-
'
|
|
504
|
-
'
|
|
505
|
-
'
|
|
506
|
-
'
|
|
450
|
+
'Title',
|
|
451
|
+
'Column',
|
|
452
|
+
'Priority',
|
|
453
|
+
'Repository',
|
|
507
454
|
'Issue #',
|
|
508
|
-
'
|
|
509
|
-
'
|
|
510
|
-
'
|
|
511
|
-
'
|
|
512
|
-
'
|
|
513
|
-
'
|
|
514
|
-
'
|
|
515
|
-
'
|
|
455
|
+
'Assignee',
|
|
456
|
+
'Model',
|
|
457
|
+
'Provider',
|
|
458
|
+
'Reasoning Effort',
|
|
459
|
+
'Workspace',
|
|
460
|
+
'Branch',
|
|
461
|
+
'Created',
|
|
462
|
+
'Updated',
|
|
516
463
|
]
|
|
517
464
|
|
|
518
465
|
function escapeCsv(val) {
|
|
@@ -554,13 +501,13 @@ export function exportTasksCsv({ store, board = 'main' }) {
|
|
|
554
501
|
rows.push(row.join(','))
|
|
555
502
|
}
|
|
556
503
|
|
|
557
|
-
//
|
|
504
|
+
// UTF-8 BOM prefix for Microsoft Excel
|
|
558
505
|
return '\uFEFF' + rows.join('\r\n')
|
|
559
506
|
}
|
|
560
507
|
|
|
561
508
|
|
|
562
509
|
/**
|
|
563
|
-
*
|
|
510
|
+
* Clean up task worktree.
|
|
564
511
|
*/
|
|
565
512
|
export async function handleCleanupWorktree({ store, task, workspaceRegistry, force, cleanupTaskWorktree }) {
|
|
566
513
|
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
@@ -574,7 +521,7 @@ export async function handleCleanupWorktree({ store, task, workspaceRegistry, fo
|
|
|
574
521
|
}
|
|
575
522
|
|
|
576
523
|
/**
|
|
577
|
-
*
|
|
524
|
+
* Get working copy diff for task.
|
|
578
525
|
*/
|
|
579
526
|
export async function handleTaskDiff({ task, getTaskDiff }) {
|
|
580
527
|
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
@@ -583,7 +530,7 @@ export async function handleTaskDiff({ task, getTaskDiff }) {
|
|
|
583
530
|
}
|
|
584
531
|
|
|
585
532
|
/**
|
|
586
|
-
*
|
|
533
|
+
* Get commit list for task.
|
|
587
534
|
*/
|
|
588
535
|
export async function handleTaskCommits({ task, getTaskCommits }) {
|
|
589
536
|
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
@@ -592,7 +539,7 @@ export async function handleTaskCommits({ task, getTaskCommits }) {
|
|
|
592
539
|
}
|
|
593
540
|
|
|
594
541
|
/**
|
|
595
|
-
*
|
|
542
|
+
* Merge task worktree branch and transition to done.
|
|
596
543
|
*/
|
|
597
544
|
export async function handleTaskMerge({ store, task, targetBranch, workspaceRegistry, mergeTaskWorktree }) {
|
|
598
545
|
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
@@ -613,13 +560,13 @@ export async function handleTaskMerge({ store, task, targetBranch, workspaceRegi
|
|
|
613
560
|
fromCol: task.column,
|
|
614
561
|
toCol: 'done',
|
|
615
562
|
source: 'manual',
|
|
616
|
-
detail:
|
|
563
|
+
detail: `accepted and merged branch ${task.branch} into ${mergeResult.targetBranch}`,
|
|
617
564
|
})
|
|
618
565
|
return { status: 200, body: { task: updated, merged: true, targetBranch: mergeResult.targetBranch } }
|
|
619
566
|
}
|
|
620
567
|
|
|
621
568
|
/**
|
|
622
|
-
*
|
|
569
|
+
* Check task worktree status (branch, dirty files, workspace).
|
|
623
570
|
*/
|
|
624
571
|
export async function handleTaskWorktreeStatus({ task, checkWorktreeDirty }) {
|
|
625
572
|
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
@@ -637,7 +584,7 @@ export async function handleTaskWorktreeStatus({ task, checkWorktreeDirty }) {
|
|
|
637
584
|
}
|
|
638
585
|
|
|
639
586
|
/**
|
|
640
|
-
*
|
|
587
|
+
* Update Definition of Done checklist for task.
|
|
641
588
|
*/
|
|
642
589
|
export function handleTaskChecklist({ store, task, input }) {
|
|
643
590
|
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
@@ -647,7 +594,7 @@ export function handleTaskChecklist({ store, task, input }) {
|
|
|
647
594
|
}
|
|
648
595
|
|
|
649
596
|
/**
|
|
650
|
-
*
|
|
597
|
+
* Set completion report for task.
|
|
651
598
|
*/
|
|
652
599
|
export function handleTaskReport({ store, task, input }) {
|
|
653
600
|
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
@@ -656,7 +603,7 @@ export function handleTaskReport({ store, task, input }) {
|
|
|
656
603
|
}
|
|
657
604
|
|
|
658
605
|
/**
|
|
659
|
-
*
|
|
606
|
+
* Get comments for task.
|
|
660
607
|
*/
|
|
661
608
|
export function handleGetTaskComments({ task }) {
|
|
662
609
|
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
@@ -664,7 +611,7 @@ export function handleGetTaskComments({ task }) {
|
|
|
664
611
|
}
|
|
665
612
|
|
|
666
613
|
/**
|
|
667
|
-
*
|
|
614
|
+
* Add comment to task.
|
|
668
615
|
*/
|
|
669
616
|
export function handleAddTaskComment({ store, task, input }) {
|
|
670
617
|
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
@@ -676,7 +623,7 @@ export function handleAddTaskComment({ store, task, input }) {
|
|
|
676
623
|
}
|
|
677
624
|
|
|
678
625
|
/**
|
|
679
|
-
*
|
|
626
|
+
* Accept task review (transition to done).
|
|
680
627
|
*/
|
|
681
628
|
export function handleAcceptTask({ store, task }) {
|
|
682
629
|
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
@@ -685,7 +632,7 @@ export function handleAcceptTask({ store, task }) {
|
|
|
685
632
|
}
|
|
686
633
|
|
|
687
634
|
/**
|
|
688
|
-
*
|
|
635
|
+
* Reject task review (return to in_progress).
|
|
689
636
|
*/
|
|
690
637
|
export function handleRejectTask({ store, task, input }) {
|
|
691
638
|
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
@@ -696,7 +643,7 @@ export function handleRejectTask({ store, task, input }) {
|
|
|
696
643
|
}
|
|
697
644
|
|
|
698
645
|
/**
|
|
699
|
-
*
|
|
646
|
+
* Save task progress dump snapshot.
|
|
700
647
|
*/
|
|
701
648
|
export function handleProgressDump({ store, task, input, formatProgressDump }) {
|
|
702
649
|
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|