@goodandready/dsh-kanban 0.2.34 → 0.2.36
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 +8 -0
- package/lib/client.js +3800 -3587
- package/lib/config.js +10 -0
- package/lib/dependencies.js +175 -0
- package/lib/index.js +110 -1255
- package/lib/routes/board.js +286 -0
- package/lib/routes/security.js +274 -0
- package/lib/routes/task-actions.js +418 -0
- package/lib/routes/task-worktree.js +107 -0
- package/lib/routes/task.js +237 -0
- package/lib/routes.js +73 -1209
- package/lib/server/route-handlers.js +367 -0
- package/lib/server/services.js +225 -0
- package/lib/server/task-handler.js +569 -0
- package/lib/server/timers.js +204 -0
- package/lib/store.js +23 -2
- package/lib/time-machine.js +214 -0
- package/lib/watchdog.js +169 -0
- package/package.json +1 -1
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
import { isTimeMachineAvailable, listTaskSnapshots, createCheckpointSnapshot, rollbackToSnapshot } from '../time-machine.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Extended actions for task route handlers (comments, reports, checklist, triage, subtasks, PR, nudge).
|
|
5
|
+
* Zero Cyrillic in lib/ according to Issue #366.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export function handleTaskChecklist({ store, task, input }) {
|
|
9
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
10
|
+
const list = Array.isArray(input?.checklist) ? input.checklist : Array.isArray(input) ? input : []
|
|
11
|
+
const updated = store.updateChecklist(task.id, list)
|
|
12
|
+
return { status: 200, body: { task: updated } }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Set completion report for task.
|
|
17
|
+
*/
|
|
18
|
+
export function handleTaskReport({ store, task, input }) {
|
|
19
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
20
|
+
const updated = store.setReport(task.id, input)
|
|
21
|
+
return { status: 200, body: { task: updated } }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Get comments for task.
|
|
26
|
+
*/
|
|
27
|
+
export function handleGetTaskComments({ task }) {
|
|
28
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
29
|
+
return { status: 200, body: { comments: task.comments || [] } }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Add comment to task.
|
|
34
|
+
*/
|
|
35
|
+
export function handleAddTaskComment({ store, task, input }) {
|
|
36
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
37
|
+
const author = input?.author || 'user'
|
|
38
|
+
const role = input?.role || 'user'
|
|
39
|
+
const text = input?.text || ''
|
|
40
|
+
const updated = store.addComment(task.id, { author, role, text })
|
|
41
|
+
return { status: 200, body: { task: updated } }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Accept task review (transition to done).
|
|
46
|
+
*/
|
|
47
|
+
export function handleAcceptTask({ store, task }) {
|
|
48
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
49
|
+
const updated = store.acceptTask(task.id)
|
|
50
|
+
return { status: 200, body: { task: updated } }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Reject task review (return to in_progress).
|
|
55
|
+
*/
|
|
56
|
+
export function handleRejectTask({ store, task, input }) {
|
|
57
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
58
|
+
const reason = String(input?.reason || '').trim()
|
|
59
|
+
if (!reason) return { status: 400, body: { error: 'reason-required' } }
|
|
60
|
+
const updated = store.rejectTask(task.id, reason)
|
|
61
|
+
return { status: 200, body: { task: updated } }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Save task progress dump snapshot.
|
|
66
|
+
*/
|
|
67
|
+
export function handleProgressDump({ store, task, input, formatProgressDump }) {
|
|
68
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
69
|
+
const formatted = typeof input === 'string' ? input : formatProgressDump(input)
|
|
70
|
+
const updated = store.updateTask(task.id, { progressDump: formatted })
|
|
71
|
+
return { status: 200, body: { task: updated } }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Decompose a task into subtasks and make the parent an Epic.
|
|
76
|
+
*/
|
|
77
|
+
export async function handleTriageTask({ store, gitea, task, input }) {
|
|
78
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
79
|
+
const subtasksInput = Array.isArray(input?.subtasks) ? input.subtasks : []
|
|
80
|
+
if (subtasksInput.length === 0) {
|
|
81
|
+
return { status: 400, body: { error: 'subtasks-required' } }
|
|
82
|
+
}
|
|
83
|
+
const createGitea = input?.createGiteaIssues === true && Boolean(task.owner && task.repo && gitea)
|
|
84
|
+
|
|
85
|
+
// Convert parent into Epic
|
|
86
|
+
store.updateTask(task.id, { isEpic: true })
|
|
87
|
+
|
|
88
|
+
const created = []
|
|
89
|
+
for (const item of subtasksInput) {
|
|
90
|
+
const title = String(item?.title ?? '').trim()
|
|
91
|
+
if (!title) continue
|
|
92
|
+
const body = String(item?.body ?? '').trim()
|
|
93
|
+
let issueNumber = null
|
|
94
|
+
let issueUrl = null
|
|
95
|
+
|
|
96
|
+
if (createGitea) {
|
|
97
|
+
try {
|
|
98
|
+
const issueBody = body ? `${body}\n\nParent epic: #${task.issueNumber || task.id}` : `Parent epic: #${task.issueNumber || task.id}`
|
|
99
|
+
const giteaIssue = await gitea.createIssue({
|
|
100
|
+
owner: task.owner,
|
|
101
|
+
repo: task.repo,
|
|
102
|
+
title,
|
|
103
|
+
body: issueBody,
|
|
104
|
+
})
|
|
105
|
+
if (giteaIssue?.number) {
|
|
106
|
+
issueNumber = giteaIssue.number
|
|
107
|
+
issueUrl = giteaIssue.html_url || null
|
|
108
|
+
}
|
|
109
|
+
} catch { /* ignore gitea subtask creation failure */ }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const sub = store.createTask({
|
|
113
|
+
board: task.board || 'main',
|
|
114
|
+
column: 'backlog',
|
|
115
|
+
title,
|
|
116
|
+
body,
|
|
117
|
+
parentId: task.id,
|
|
118
|
+
owner: task.owner || null,
|
|
119
|
+
repo: task.repo || null,
|
|
120
|
+
issueNumber,
|
|
121
|
+
issueUrl,
|
|
122
|
+
labels: Array.isArray(item?.labels) ? item.labels : [],
|
|
123
|
+
workspaceId: task.workspaceId || '',
|
|
124
|
+
})
|
|
125
|
+
created.push(sub)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
store.addTransition({
|
|
129
|
+
taskId: task.id,
|
|
130
|
+
fromCol: task.column,
|
|
131
|
+
toCol: task.column,
|
|
132
|
+
source: 'manual',
|
|
133
|
+
detail: `decomposed into ${created.length} subtasks (epic triage)`,
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
status: 200,
|
|
138
|
+
body: {
|
|
139
|
+
epic: store.getTask(task.id),
|
|
140
|
+
subtasks: store.listSubtasks(task.id),
|
|
141
|
+
counts: store.countSubtasks(task.id),
|
|
142
|
+
createdCount: created.length,
|
|
143
|
+
},
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Get subtasks of a task.
|
|
149
|
+
*/
|
|
150
|
+
export function handleGetSubtasks({ store, task }) {
|
|
151
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
152
|
+
return {
|
|
153
|
+
status: 200,
|
|
154
|
+
body: {
|
|
155
|
+
subtasks: store.listSubtasks(task.id),
|
|
156
|
+
counts: store.countSubtasks(task.id),
|
|
157
|
+
},
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Create a single subtask linked to a parent task.
|
|
163
|
+
*/
|
|
164
|
+
export async function handleCreateSubtask({ store, gitea, task, input }) {
|
|
165
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
166
|
+
const title = String(input?.title ?? '').trim()
|
|
167
|
+
if (!title) return { status: 400, body: { error: 'title-required' } }
|
|
168
|
+
const body = String(input?.body ?? '').trim()
|
|
169
|
+
let issueNumber = null
|
|
170
|
+
let issueUrl = null
|
|
171
|
+
|
|
172
|
+
if (input?.createGiteaIssue === true && task.owner && task.repo && gitea) {
|
|
173
|
+
try {
|
|
174
|
+
const issueBody = body ? `${body}\n\nParent epic: #${task.issueNumber || task.id}` : `Parent epic: #${task.issueNumber || task.id}`
|
|
175
|
+
const giteaIssue = await gitea.createIssue({
|
|
176
|
+
owner: task.owner,
|
|
177
|
+
repo: task.repo,
|
|
178
|
+
title,
|
|
179
|
+
body: issueBody,
|
|
180
|
+
})
|
|
181
|
+
if (giteaIssue?.number) {
|
|
182
|
+
issueNumber = giteaIssue.number
|
|
183
|
+
issueUrl = giteaIssue.html_url || null
|
|
184
|
+
}
|
|
185
|
+
} catch { /* ignore gitea subtask creation failure */ }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
store.updateTask(task.id, { isEpic: true })
|
|
189
|
+
const sub = store.createTask({
|
|
190
|
+
board: task.board || 'main',
|
|
191
|
+
column: input?.column || 'backlog',
|
|
192
|
+
title,
|
|
193
|
+
body,
|
|
194
|
+
parentId: task.id,
|
|
195
|
+
owner: task.owner || null,
|
|
196
|
+
repo: task.repo || null,
|
|
197
|
+
issueNumber,
|
|
198
|
+
issueUrl,
|
|
199
|
+
labels: Array.isArray(input?.labels) ? input.labels : [],
|
|
200
|
+
workspaceId: task.workspaceId || '',
|
|
201
|
+
})
|
|
202
|
+
return { status: 200, body: { subtask: sub, counts: store.countSubtasks(task.id) } }
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Toggle a checklist item atomically by index or text.
|
|
207
|
+
*/
|
|
208
|
+
export function handleToggleChecklistItem({ store, task, input }) {
|
|
209
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
210
|
+
const current = Array.isArray(task.checklist) ? [...task.checklist] : []
|
|
211
|
+
let targetIndex = -1
|
|
212
|
+
|
|
213
|
+
if (typeof input?.index === 'number' && input.index >= 0 && input.index < current.length) {
|
|
214
|
+
targetIndex = input.index
|
|
215
|
+
} else if (typeof input?.text === 'string' && input.text.trim()) {
|
|
216
|
+
const q = input.text.trim().toLowerCase()
|
|
217
|
+
targetIndex = current.findIndex((item) => {
|
|
218
|
+
const t = typeof item === 'string' ? item : item?.text
|
|
219
|
+
return typeof t === 'string' && t.toLowerCase().includes(q)
|
|
220
|
+
})
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (targetIndex === -1) {
|
|
224
|
+
return { status: 404, body: { error: 'checklist-item-not-found' } }
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const item = current[targetIndex]
|
|
228
|
+
let updatedItem
|
|
229
|
+
if (typeof item === 'string') {
|
|
230
|
+
const checked = input?.checked !== undefined ? Boolean(input.checked) : true
|
|
231
|
+
updatedItem = { text: item, completed: checked, done: checked }
|
|
232
|
+
} else {
|
|
233
|
+
const isCurrentlyDone = item.completed !== undefined ? Boolean(item.completed) : Boolean(item.done)
|
|
234
|
+
const checked = input?.checked !== undefined ? Boolean(input.checked) : !isCurrentlyDone
|
|
235
|
+
updatedItem = { ...item, completed: checked, done: checked }
|
|
236
|
+
}
|
|
237
|
+
current[targetIndex] = updatedItem
|
|
238
|
+
const updated = store.updateChecklist(task.id, current)
|
|
239
|
+
return { status: 200, body: { task: updated, item: updatedItem, index: targetIndex } }
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Create a Gitea Pull Request for a task.
|
|
244
|
+
*/
|
|
245
|
+
export async function handleCreateTaskPr({ store, gitea, task, input }) {
|
|
246
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
247
|
+
if (!task.owner || !task.repo) {
|
|
248
|
+
return { status: 400, body: { error: 'task-no-repo' } }
|
|
249
|
+
}
|
|
250
|
+
const head = String(input?.head || task.branch || '').trim()
|
|
251
|
+
if (!head) return { status: 400, body: { error: 'branch-required' } }
|
|
252
|
+
const base = String(input?.base || 'main').trim()
|
|
253
|
+
const title = String(input?.title || task.title || `PR: ${task.title}`).trim()
|
|
254
|
+
const body = String(input?.body || (task.issueNumber ? `Closes #${task.issueNumber}` : '')).trim()
|
|
255
|
+
|
|
256
|
+
try {
|
|
257
|
+
const pr = await gitea.createPullRequest({
|
|
258
|
+
owner: task.owner,
|
|
259
|
+
repo: task.repo,
|
|
260
|
+
title,
|
|
261
|
+
body,
|
|
262
|
+
head,
|
|
263
|
+
base,
|
|
264
|
+
})
|
|
265
|
+
store.addTransition({
|
|
266
|
+
taskId: task.id,
|
|
267
|
+
fromCol: task.column,
|
|
268
|
+
toCol: task.column,
|
|
269
|
+
source: 'manual',
|
|
270
|
+
detail: `Created PR #${pr?.number || ''}: ${head} -> ${base}`,
|
|
271
|
+
})
|
|
272
|
+
return { status: 200, body: { pr, prUrl: pr?.html_url } }
|
|
273
|
+
} catch (err) {
|
|
274
|
+
return { status: 502, body: { error: 'gitea-pr-failed', detail: err?.message } }
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Handle interactive live steering (nudge) for an active task agent (#373).
|
|
279
|
+
*
|
|
280
|
+
* @param {{
|
|
281
|
+
* store: object,
|
|
282
|
+
* id: string,
|
|
283
|
+
* prompt: string,
|
|
284
|
+
* agents?: object,
|
|
285
|
+
* createMessage?: Function,
|
|
286
|
+
* eventHub?: object,
|
|
287
|
+
* now?: number
|
|
288
|
+
* }} options
|
|
289
|
+
* @returns {Promise<{ status: number, body: object }>}
|
|
290
|
+
*/
|
|
291
|
+
export async function handleTaskNudge({ store, id, prompt, agents, createMessage, eventHub, now = Date.now() }) {
|
|
292
|
+
if (!store || !id) return { status: 400, body: { error: 'invalid-parameters' } }
|
|
293
|
+
const task = store.getTask(id)
|
|
294
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
295
|
+
|
|
296
|
+
const text = typeof prompt === 'string' ? prompt.trim() : ''
|
|
297
|
+
if (!text) return { status: 400, body: { error: 'prompt-required' } }
|
|
298
|
+
|
|
299
|
+
const sessionId = typeof task.sessionId === 'string' ? task.sessionId : ''
|
|
300
|
+
if (!sessionId) return { status: 409, body: { error: 'no-session' } }
|
|
301
|
+
|
|
302
|
+
let agent
|
|
303
|
+
try { agent = agents?.get?.(sessionId) } catch { agent = undefined }
|
|
304
|
+
if (!agent || typeof agent.followup !== 'function') {
|
|
305
|
+
return { status: 409, body: { error: 'session-not-live' } }
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const msg = typeof createMessage === 'function'
|
|
309
|
+
? createMessage({
|
|
310
|
+
content: [{ type: 'text', text }],
|
|
311
|
+
source: { kind: 'dsh-kanban', plugin: 'dsh-kanban', form: 'task-nudge' },
|
|
312
|
+
})
|
|
313
|
+
: { content: [{ type: 'text', text }] }
|
|
314
|
+
|
|
315
|
+
agent.followup(msg)
|
|
316
|
+
|
|
317
|
+
// Append user steering comment to task
|
|
318
|
+
if (typeof store.addComment === 'function') {
|
|
319
|
+
store.addComment(id, {
|
|
320
|
+
author: 'user',
|
|
321
|
+
role: 'user',
|
|
322
|
+
text: `[Steering Nudge] ${text}`,
|
|
323
|
+
})
|
|
324
|
+
} else if (typeof store.addTaskComment === 'function') {
|
|
325
|
+
store.addTaskComment(id, {
|
|
326
|
+
author: 'user',
|
|
327
|
+
role: 'user',
|
|
328
|
+
text: `[Steering Nudge] ${text}`,
|
|
329
|
+
})
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Broadcast change via eventHub
|
|
333
|
+
if (eventHub && typeof eventHub.broadcast === 'function') {
|
|
334
|
+
eventHub.broadcast('task:change', { id, col: task.col || task.column })
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return {
|
|
338
|
+
status: 200,
|
|
339
|
+
body: {
|
|
340
|
+
success: true,
|
|
341
|
+
id,
|
|
342
|
+
sessionId,
|
|
343
|
+
prompt: text,
|
|
344
|
+
nudgedAt: now,
|
|
345
|
+
},
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Handle listing or creating snapshots via dsh-time-machine (#377).
|
|
351
|
+
*/
|
|
352
|
+
export async function handleTaskSnapshot({ ctx, store, id, method, body, config, env, cwdOf, logger }) {
|
|
353
|
+
const task = store.getTask(id)
|
|
354
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
355
|
+
|
|
356
|
+
if (method === 'GET') {
|
|
357
|
+
const available = isTimeMachineAvailable(ctx)
|
|
358
|
+
const snapshots = available ? await listTaskSnapshots({ ctx, task }) : []
|
|
359
|
+
return {
|
|
360
|
+
status: 200,
|
|
361
|
+
body: {
|
|
362
|
+
available,
|
|
363
|
+
inceptionSnapshotId: task.inceptionSnapshotId || '',
|
|
364
|
+
snapshots,
|
|
365
|
+
},
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
if (method === 'POST') {
|
|
370
|
+
const label = typeof body?.label === 'string' ? body.label.trim() : ''
|
|
371
|
+
const res = await createCheckpointSnapshot({ ctx, task, label, config, env, cwdOf, logger })
|
|
372
|
+
if (!res.available) {
|
|
373
|
+
return {
|
|
374
|
+
status: 503,
|
|
375
|
+
body: {
|
|
376
|
+
error: 'time-machine-unavailable',
|
|
377
|
+
message: 'Plugin @goodandready/dsh-time-machine is not installed or active in this profile.',
|
|
378
|
+
},
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
if (!res.created) {
|
|
382
|
+
return { status: 500, body: { error: 'snapshot-failed', message: res.error } }
|
|
383
|
+
}
|
|
384
|
+
return { status: 201, body: { success: true, snapshot: res.snapshot } }
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
return { status: 405, body: { error: 'method-not-allowed' } }
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Handle rolling back task worktree to inception or specified snapshot (#377).
|
|
392
|
+
*/
|
|
393
|
+
export async function handleTaskRollbackSnapshot({ ctx, store, id, body, config, env, cwdOf, logger, eventHub }) {
|
|
394
|
+
const task = store.getTask(id)
|
|
395
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
396
|
+
|
|
397
|
+
const snapshotId = typeof body?.snapshotId === 'string' ? body.snapshotId.trim() : task.inceptionSnapshotId
|
|
398
|
+
const res = await rollbackToSnapshot({ ctx, task, snapshotId, config, env, cwdOf, logger })
|
|
399
|
+
if (!res.available) {
|
|
400
|
+
return { status: 503, body: { error: 'time-machine-unavailable', message: res.message } }
|
|
401
|
+
}
|
|
402
|
+
if (!res.rolledBack) {
|
|
403
|
+
return { status: 400, body: { error: res.error, message: res.message } }
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
if (eventHub && typeof eventHub.broadcast === 'function') {
|
|
407
|
+
eventHub.broadcast('task:change', { taskId: id, action: 'rollback', snapshotId: res.snapshotId })
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return {
|
|
411
|
+
status: 200,
|
|
412
|
+
body: {
|
|
413
|
+
success: true,
|
|
414
|
+
snapshotId: res.snapshotId,
|
|
415
|
+
result: res.result,
|
|
416
|
+
},
|
|
417
|
+
}
|
|
418
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Worktree actions for task route handlers.
|
|
3
|
+
* Zero Cyrillic in lib/ according to Issue #366.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export async function handleCleanupWorktree({
|
|
7
|
+
store, task, workspaceRegistry, force, cleanupTaskWorktree,
|
|
8
|
+
config, repoDir, cwdOf, env, gitRunner,
|
|
9
|
+
}) {
|
|
10
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
11
|
+
const out = await cleanupTaskWorktree({
|
|
12
|
+
store,
|
|
13
|
+
task,
|
|
14
|
+
workspaceRegistry,
|
|
15
|
+
force: force === true,
|
|
16
|
+
config,
|
|
17
|
+
repoDir,
|
|
18
|
+
cwdOf,
|
|
19
|
+
env,
|
|
20
|
+
gitRunner,
|
|
21
|
+
})
|
|
22
|
+
const status = out.status || (out.error ? (out.error === 'unfenced-worktree-path' ? 403 : 500) : 200)
|
|
23
|
+
return { status, body: out }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Get working copy diff for task.
|
|
28
|
+
*/
|
|
29
|
+
export async function handleTaskDiff({ task, getTaskDiff, getTaskDiffInspector }) {
|
|
30
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
31
|
+
if (typeof getTaskDiffInspector === 'function') {
|
|
32
|
+
const inspectorResult = await getTaskDiffInspector({ worktreePath: task.worktree })
|
|
33
|
+
return { status: 200, body: inspectorResult }
|
|
34
|
+
}
|
|
35
|
+
const diffResult = await getTaskDiff({ worktreePath: task.worktree })
|
|
36
|
+
return { status: 200, body: diffResult }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Discard file changes in task worktree (#371).
|
|
41
|
+
*/
|
|
42
|
+
export async function handleTaskDiscard({ task, file, discardTaskFile, gitRunner }) {
|
|
43
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
44
|
+
if (!file || typeof file !== 'string') return { status: 400, body: { error: 'file-required' } }
|
|
45
|
+
if (!task.worktree) return { status: 400, body: { error: 'no-worktree' } }
|
|
46
|
+
const result = await discardTaskFile({ worktreePath: task.worktree, file, gitRunner })
|
|
47
|
+
return { status: result.success ? 200 : 400, body: result }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Get commit list for task.
|
|
52
|
+
*/
|
|
53
|
+
export async function handleTaskCommits({ task, getTaskCommits, gitRunner }) {
|
|
54
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
55
|
+
const commits = await getTaskCommits({ worktreePath: task.worktree, gitRunner })
|
|
56
|
+
return { status: 200, body: { commits } }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Merge task worktree branch and transition to done.
|
|
61
|
+
*/
|
|
62
|
+
export async function handleTaskMerge({ store, task, targetBranch, workspaceRegistry, mergeTaskWorktree, gitRunner }) {
|
|
63
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
64
|
+
const mergeResult = await mergeTaskWorktree({
|
|
65
|
+
task,
|
|
66
|
+
targetBranch: typeof targetBranch === 'string' ? targetBranch.trim() : undefined,
|
|
67
|
+
workspaceRegistry,
|
|
68
|
+
gitRunner,
|
|
69
|
+
})
|
|
70
|
+
if (!mergeResult.success) {
|
|
71
|
+
return {
|
|
72
|
+
status: 400,
|
|
73
|
+
body: { error: mergeResult.error, detail: mergeResult.detail, files: mergeResult.files },
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const updated = store.acceptTask(task.id)
|
|
77
|
+
store.addTransition({
|
|
78
|
+
taskId: task.id,
|
|
79
|
+
fromCol: task.column,
|
|
80
|
+
toCol: 'done',
|
|
81
|
+
source: 'manual',
|
|
82
|
+
detail: `accepted and merged branch ${task.branch} into ${mergeResult.targetBranch}`,
|
|
83
|
+
})
|
|
84
|
+
return { status: 200, body: { task: updated, merged: true, targetBranch: mergeResult.targetBranch } }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Check task worktree status (branch, dirty files, workspace).
|
|
89
|
+
*/
|
|
90
|
+
export async function handleTaskWorktreeStatus({ task, checkWorktreeDirty }) {
|
|
91
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
92
|
+
const dirtyCheck = await checkWorktreeDirty({ worktreePath: task.worktree })
|
|
93
|
+
return {
|
|
94
|
+
status: 200,
|
|
95
|
+
body: {
|
|
96
|
+
worktree: task.worktree || '',
|
|
97
|
+
branch: task.branch || '',
|
|
98
|
+
workspaceId: task.workspaceId || '',
|
|
99
|
+
dirty: dirtyCheck.dirty,
|
|
100
|
+
files: dirtyCheck.files,
|
|
101
|
+
},
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Update Definition of Done checklist for task.
|
|
107
|
+
*/
|