@goodandready/dsh-kanban 0.2.19 → 0.2.20
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 +29 -54
- package/lib/cron.js +13 -13
- package/lib/events.js +1 -1
- package/lib/filters.js +58 -82
- package/lib/git-provider.js +23 -23
- package/lib/gitea.js +51 -109
- package/lib/github.js +27 -31
- package/lib/import.js +46 -67
- package/lib/index.js +66 -132
- package/lib/intake.js +18 -26
- package/lib/launcher.js +100 -167
- 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 +25 -26
- package/lib/plan.js +23 -45
- package/lib/power.js +2 -2
- package/lib/progress-dump.js +17 -17
- package/lib/routes.js +86 -145
- package/lib/store.js +59 -77
- package/lib/sync.js +56 -98
- package/lib/time.js +8 -17
- package/lib/transitions.js +36 -57
- package/lib/webhook.js +16 -23
- package/lib/workflow-bridge.js +57 -57
- package/lib/worktree.js +58 -58
- 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,34 +145,28 @@ 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)
|
|
@@ -210,14 +182,12 @@ export function applyMove({ store, config, id, column, beforeId, afterId, source
|
|
|
210
182
|
return { task, overLimit: limit !== undefined && count > limit }
|
|
211
183
|
}
|
|
212
184
|
|
|
213
|
-
/**
|
|
185
|
+
/** Create local board task (not linked to Gitea). */
|
|
214
186
|
export function createTask({ store, input }) {
|
|
215
187
|
const title = typeof input?.title === 'string' ? input.title.trim() : ''
|
|
216
188
|
if (title === '') return { error: 'title-required', status: 400 }
|
|
217
189
|
const board = input?.board ?? 'main'
|
|
218
|
-
//
|
|
219
|
-
// в `deploy` на простой доске значило бы спрятать её в колонке, которой там
|
|
220
|
-
// нет.
|
|
190
|
+
// Validate column against board kind; simple boards do not have deploy/cleanup.
|
|
221
191
|
const allowed = columnsOf(store.boardKind(board))
|
|
222
192
|
const column = allowed.includes(input?.column) ? input.column : 'backlog'
|
|
223
193
|
const task = store.createTask({
|
|
@@ -230,7 +200,7 @@ export function createTask({ store, input }) {
|
|
|
230
200
|
return { task }
|
|
231
201
|
}
|
|
232
202
|
|
|
233
|
-
/**
|
|
203
|
+
/** Update card fields. Column transitions are handled via applyMove. */
|
|
234
204
|
export function updateTask({ store, id, input }) {
|
|
235
205
|
const current = store.getTask(id)
|
|
236
206
|
if (current === undefined) return { error: 'task-not-found', status: 404 }
|
|
@@ -238,12 +208,9 @@ export function updateTask({ store, id, input }) {
|
|
|
238
208
|
if (typeof input?.title === 'string' && input.title.trim() !== '') patch.title = input.title.trim()
|
|
239
209
|
if (typeof input?.body === 'string') patch.body = input.body
|
|
240
210
|
if (Array.isArray(input?.labels)) patch.labels = input.labels.filter((l) => typeof l === 'string')
|
|
241
|
-
//
|
|
242
|
-
// значения отбрасываются: одно испорченное поле не должно ронять правку
|
|
243
|
-
// остальных.
|
|
211
|
+
// Priority is local to board and not synced back to Gitea.
|
|
244
212
|
if (PRIORITIES.includes(input?.priority)) patch.priority = input.priority
|
|
245
|
-
//
|
|
246
|
-
// ставит; отрицательное и мусор отбрасываются.
|
|
213
|
+
// Deadline is local. 0 clears deadline; positive value sets it.
|
|
247
214
|
if (typeof input?.dueAt === 'number' && Number.isFinite(input.dueAt) && input.dueAt >= 0) {
|
|
248
215
|
patch.dueAt = Math.floor(input.dueAt)
|
|
249
216
|
}
|
|
@@ -273,17 +240,12 @@ export function confirmPermission({ store, id }) {
|
|
|
273
240
|
return { task: store.updateTask(id, { permissionConfirmedAt: Date.now() }) }
|
|
274
241
|
}
|
|
275
242
|
|
|
276
|
-
/**
|
|
243
|
+
/** Known priority values. Empty string indicates unprioritized. */
|
|
277
244
|
const PRIORITIES = ['', 'high', 'medium', 'low']
|
|
278
245
|
|
|
279
246
|
/**
|
|
280
|
-
*
|
|
281
|
-
*
|
|
282
|
-
* Именно в тело, а не в отдельную ленту заметок и не комментарием в Gitea:
|
|
283
|
-
* владелец выбрал самый простой из трёх смыслов, и он же самый дешёвый.
|
|
284
|
-
*
|
|
285
|
-
* Дописываем на сервере, а не в браузере: чип держит копию задачи, и склейка
|
|
286
|
-
* у него потеряла бы всё, что тело нажило с момента её получения.
|
|
247
|
+
* Append note to task body.
|
|
248
|
+
* Handled on server to prevent overwriting edits made elsewhere.
|
|
287
249
|
*/
|
|
288
250
|
export function appendNote({ store, id, input }) {
|
|
289
251
|
const task = store.getTask(id)
|
|
@@ -295,10 +257,8 @@ export function appendNote({ store, id, input }) {
|
|
|
295
257
|
}
|
|
296
258
|
|
|
297
259
|
/**
|
|
298
|
-
*
|
|
299
|
-
*
|
|
300
|
-
* Возврат ставит её ровно туда, где она была: колонка и порядок сохранены, а
|
|
301
|
-
* `archivedAt` — единственное, что менялось.
|
|
260
|
+
* Archive or unarchive card.
|
|
261
|
+
* Unarchiving restores card to its original column and position.
|
|
302
262
|
*/
|
|
303
263
|
export function setArchived({ store, id, archived, now = Date.now() }) {
|
|
304
264
|
const task = store.getTask(id)
|
|
@@ -306,16 +266,14 @@ export function setArchived({ store, id, archived, now = Date.now() }) {
|
|
|
306
266
|
return { task: store.setArchived(id, archived ? now : 0) }
|
|
307
267
|
}
|
|
308
268
|
|
|
309
|
-
/**
|
|
269
|
+
/** Query archived tasks list. */
|
|
310
270
|
export function listArchive({ store }) {
|
|
311
271
|
return { tasks: store.listArchived() }
|
|
312
272
|
}
|
|
313
273
|
|
|
314
274
|
/**
|
|
315
|
-
*
|
|
316
|
-
*
|
|
317
|
-
* Пустой логин — это снятие, а не отказ: «никто» такое же законное состояние
|
|
318
|
-
* задачи, как и любой исполнитель.
|
|
275
|
+
* Assign task or clear assignment.
|
|
276
|
+
* Empty login unassigns the task.
|
|
319
277
|
*
|
|
320
278
|
* @returns {{task: object}|{error: string, status: number}}
|
|
321
279
|
*/
|
|
@@ -330,7 +288,7 @@ export function assignTask({ store, id, login }) {
|
|
|
330
288
|
fromCol: task.column,
|
|
331
289
|
toCol: task.column,
|
|
332
290
|
source: 'manual',
|
|
333
|
-
detail: who === '' ? '
|
|
291
|
+
detail: who === '' ? 'unassigned' : `assigned: ${who}`,
|
|
334
292
|
})
|
|
335
293
|
return { task: store.getTask(id) }
|
|
336
294
|
}
|
|
@@ -342,18 +300,11 @@ export function deleteTask({ store, id }) {
|
|
|
342
300
|
}
|
|
343
301
|
|
|
344
302
|
/**
|
|
345
|
-
*
|
|
346
|
-
*
|
|
347
|
-
* Разбираем здесь, а не в браузере: браузерная половина — отдельный бандл и
|
|
348
|
-
* позвать `lib/markdown.js` не может. Заодно тело не едет в разметку строкой,
|
|
349
|
-
* и подставить туда чужое нечем.
|
|
303
|
+
* Parse task markdown body into structured block elements.
|
|
304
|
+
* Done on server so client bundle does not need markdown parser.
|
|
350
305
|
*/
|
|
351
306
|
/**
|
|
352
|
-
*
|
|
353
|
-
*
|
|
354
|
-
* Считается по журналу переходов и ничего не записывает. Порог «залежалось»
|
|
355
|
-
* приходит запросом, а не настройкой: это вопрос, который задают по-разному в
|
|
356
|
-
* разные дни, а не свойство доски.
|
|
307
|
+
* Board metrics: WIP distribution and stale tasks.
|
|
357
308
|
*
|
|
358
309
|
* @returns {{columns: object[], done: object, stale: object[], tasks: number}}
|
|
359
310
|
*/
|
|
@@ -380,10 +331,8 @@ export function boardMetrics({ store, board = 'main', now = Date.now(), staleDay
|
|
|
380
331
|
}
|
|
381
332
|
|
|
382
333
|
/**
|
|
383
|
-
*
|
|
384
|
-
*
|
|
385
|
-
* Только когда сессия у задачи записана, а живого агента за ней нет: у живой
|
|
386
|
-
* продолжать нечего, у задачи без сессии — нечего возобновлять.
|
|
334
|
+
* Check whether a task can be resumed.
|
|
335
|
+
* Eligible when task has recorded sessionId but no active agent is running.
|
|
387
336
|
*/
|
|
388
337
|
export function canRevive(task, live) {
|
|
389
338
|
const has = typeof task?.sessionId === 'string' && task.sessionId !== ''
|
|
@@ -401,14 +350,13 @@ export function taskLog({ store, id }) {
|
|
|
401
350
|
return { transitions: store.listTransitions(id) }
|
|
402
351
|
}
|
|
403
352
|
|
|
404
|
-
/**
|
|
353
|
+
/** Find task associated with session; absence is normal response. */
|
|
405
354
|
export function taskBySession({ store, sessionId, agents }) {
|
|
406
355
|
const task = store.findTaskBySession(sessionId)
|
|
407
356
|
if (task === undefined) return { task: null }
|
|
408
357
|
let live
|
|
409
358
|
try { live = agents?.get?.(sessionId) } catch { live = undefined }
|
|
410
|
-
//
|
|
411
|
-
// `deploy` задаче с простой доски значит звать в колонку, которой нет.
|
|
359
|
+
// Board kind returned with task so UI displays matching column choices.
|
|
412
360
|
return {
|
|
413
361
|
task,
|
|
414
362
|
columns: columnsOf(store.boardKind(task.board)),
|
|
@@ -417,21 +365,14 @@ export function taskBySession({ store, sessionId, agents }) {
|
|
|
417
365
|
}
|
|
418
366
|
|
|
419
367
|
/**
|
|
420
|
-
*
|
|
421
|
-
*
|
|
422
|
-
* Отдаём всё, что есть в хранилище: доски, задачи (включая архивные) и журнал
|
|
423
|
-
* переходов. Импорт восстанавливает это как есть.
|
|
368
|
+
* Export full board snapshot for backup or migration.
|
|
424
369
|
*/
|
|
425
370
|
export function exportBoard({ store }) {
|
|
426
371
|
return store.exportAll()
|
|
427
372
|
}
|
|
428
373
|
|
|
429
374
|
/**
|
|
430
|
-
*
|
|
431
|
-
*
|
|
432
|
-
* Идемпотентно: существующая задача с тем же id не перезаписывается, а
|
|
433
|
-
* пропускается — повторный импорт не плодит дубли. Доски создаются, если их
|
|
434
|
-
* ещё нет.
|
|
375
|
+
* Restore board from backup snapshot (idempotent; skips existing IDs).
|
|
435
376
|
*
|
|
436
377
|
* @returns {{imported: number, skipped: number, error?: string}}
|
|
437
378
|
*/
|
|
@@ -491,28 +432,28 @@ export function importBoard({ store, input }) {
|
|
|
491
432
|
}
|
|
492
433
|
|
|
493
434
|
/**
|
|
494
|
-
*
|
|
435
|
+
* Export tasks to Excel-compatible CSV with UTF-8 BOM (#223).
|
|
495
436
|
*
|
|
496
437
|
* @param {{store: object, board?: string}} options
|
|
497
|
-
* @returns {string} CSV
|
|
438
|
+
* @returns {string} CSV string with UTF-8 BOM prefix
|
|
498
439
|
*/
|
|
499
440
|
export function exportTasksCsv({ store, board = 'main' }) {
|
|
500
441
|
const tasks = store.listTasks({ board })
|
|
501
442
|
const headers = [
|
|
502
443
|
'ID',
|
|
503
|
-
'
|
|
504
|
-
'
|
|
505
|
-
'
|
|
506
|
-
'
|
|
444
|
+
'Title',
|
|
445
|
+
'Column',
|
|
446
|
+
'Priority',
|
|
447
|
+
'Repository',
|
|
507
448
|
'Issue #',
|
|
508
|
-
'
|
|
509
|
-
'
|
|
510
|
-
'
|
|
511
|
-
'
|
|
512
|
-
'
|
|
513
|
-
'
|
|
514
|
-
'
|
|
515
|
-
'
|
|
449
|
+
'Assignee',
|
|
450
|
+
'Model',
|
|
451
|
+
'Provider',
|
|
452
|
+
'Reasoning Effort',
|
|
453
|
+
'Workspace',
|
|
454
|
+
'Branch',
|
|
455
|
+
'Created',
|
|
456
|
+
'Updated',
|
|
516
457
|
]
|
|
517
458
|
|
|
518
459
|
function escapeCsv(val) {
|
|
@@ -554,13 +495,13 @@ export function exportTasksCsv({ store, board = 'main' }) {
|
|
|
554
495
|
rows.push(row.join(','))
|
|
555
496
|
}
|
|
556
497
|
|
|
557
|
-
//
|
|
498
|
+
// UTF-8 BOM prefix for Microsoft Excel
|
|
558
499
|
return '\uFEFF' + rows.join('\r\n')
|
|
559
500
|
}
|
|
560
501
|
|
|
561
502
|
|
|
562
503
|
/**
|
|
563
|
-
*
|
|
504
|
+
* Clean up task worktree.
|
|
564
505
|
*/
|
|
565
506
|
export async function handleCleanupWorktree({ store, task, workspaceRegistry, force, cleanupTaskWorktree }) {
|
|
566
507
|
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
@@ -574,7 +515,7 @@ export async function handleCleanupWorktree({ store, task, workspaceRegistry, fo
|
|
|
574
515
|
}
|
|
575
516
|
|
|
576
517
|
/**
|
|
577
|
-
*
|
|
518
|
+
* Get working copy diff for task.
|
|
578
519
|
*/
|
|
579
520
|
export async function handleTaskDiff({ task, getTaskDiff }) {
|
|
580
521
|
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
@@ -583,7 +524,7 @@ export async function handleTaskDiff({ task, getTaskDiff }) {
|
|
|
583
524
|
}
|
|
584
525
|
|
|
585
526
|
/**
|
|
586
|
-
*
|
|
527
|
+
* Get commit list for task.
|
|
587
528
|
*/
|
|
588
529
|
export async function handleTaskCommits({ task, getTaskCommits }) {
|
|
589
530
|
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
@@ -592,7 +533,7 @@ export async function handleTaskCommits({ task, getTaskCommits }) {
|
|
|
592
533
|
}
|
|
593
534
|
|
|
594
535
|
/**
|
|
595
|
-
*
|
|
536
|
+
* Merge task worktree branch and transition to done.
|
|
596
537
|
*/
|
|
597
538
|
export async function handleTaskMerge({ store, task, targetBranch, workspaceRegistry, mergeTaskWorktree }) {
|
|
598
539
|
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
@@ -613,13 +554,13 @@ export async function handleTaskMerge({ store, task, targetBranch, workspaceRegi
|
|
|
613
554
|
fromCol: task.column,
|
|
614
555
|
toCol: 'done',
|
|
615
556
|
source: 'manual',
|
|
616
|
-
detail:
|
|
557
|
+
detail: `accepted and merged branch ${task.branch} into ${mergeResult.targetBranch}`,
|
|
617
558
|
})
|
|
618
559
|
return { status: 200, body: { task: updated, merged: true, targetBranch: mergeResult.targetBranch } }
|
|
619
560
|
}
|
|
620
561
|
|
|
621
562
|
/**
|
|
622
|
-
*
|
|
563
|
+
* Check task worktree status (branch, dirty files, workspace).
|
|
623
564
|
*/
|
|
624
565
|
export async function handleTaskWorktreeStatus({ task, checkWorktreeDirty }) {
|
|
625
566
|
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
@@ -637,7 +578,7 @@ export async function handleTaskWorktreeStatus({ task, checkWorktreeDirty }) {
|
|
|
637
578
|
}
|
|
638
579
|
|
|
639
580
|
/**
|
|
640
|
-
*
|
|
581
|
+
* Update Definition of Done checklist for task.
|
|
641
582
|
*/
|
|
642
583
|
export function handleTaskChecklist({ store, task, input }) {
|
|
643
584
|
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
@@ -647,7 +588,7 @@ export function handleTaskChecklist({ store, task, input }) {
|
|
|
647
588
|
}
|
|
648
589
|
|
|
649
590
|
/**
|
|
650
|
-
*
|
|
591
|
+
* Set completion report for task.
|
|
651
592
|
*/
|
|
652
593
|
export function handleTaskReport({ store, task, input }) {
|
|
653
594
|
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
@@ -656,7 +597,7 @@ export function handleTaskReport({ store, task, input }) {
|
|
|
656
597
|
}
|
|
657
598
|
|
|
658
599
|
/**
|
|
659
|
-
*
|
|
600
|
+
* Get comments for task.
|
|
660
601
|
*/
|
|
661
602
|
export function handleGetTaskComments({ task }) {
|
|
662
603
|
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
@@ -664,7 +605,7 @@ export function handleGetTaskComments({ task }) {
|
|
|
664
605
|
}
|
|
665
606
|
|
|
666
607
|
/**
|
|
667
|
-
*
|
|
608
|
+
* Add comment to task.
|
|
668
609
|
*/
|
|
669
610
|
export function handleAddTaskComment({ store, task, input }) {
|
|
670
611
|
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
@@ -676,7 +617,7 @@ export function handleAddTaskComment({ store, task, input }) {
|
|
|
676
617
|
}
|
|
677
618
|
|
|
678
619
|
/**
|
|
679
|
-
*
|
|
620
|
+
* Accept task review (transition to done).
|
|
680
621
|
*/
|
|
681
622
|
export function handleAcceptTask({ store, task }) {
|
|
682
623
|
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
@@ -685,7 +626,7 @@ export function handleAcceptTask({ store, task }) {
|
|
|
685
626
|
}
|
|
686
627
|
|
|
687
628
|
/**
|
|
688
|
-
*
|
|
629
|
+
* Reject task review (return to in_progress).
|
|
689
630
|
*/
|
|
690
631
|
export function handleRejectTask({ store, task, input }) {
|
|
691
632
|
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
@@ -696,7 +637,7 @@ export function handleRejectTask({ store, task, input }) {
|
|
|
696
637
|
}
|
|
697
638
|
|
|
698
639
|
/**
|
|
699
|
-
*
|
|
640
|
+
* Save task progress dump snapshot.
|
|
700
641
|
*/
|
|
701
642
|
export function handleProgressDump({ store, task, input, formatProgressDump }) {
|
|
702
643
|
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|