@goodandready/dsh-kanban 0.2.35 → 0.2.37
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 +3884 -3567
- package/lib/config.js +4 -0
- package/lib/dependencies.js +175 -0
- package/lib/launcher.js +4 -0
- package/lib/plan-generator.js +226 -0
- package/lib/pr-generator.js +270 -0
- package/lib/routes/board.js +4 -0
- package/lib/routes/task-actions.js +160 -1
- package/lib/routes/task.js +27 -1
- package/lib/routes.js +4 -0
- package/lib/server/task-handler.js +51 -0
- package/lib/store.js +25 -2
- package/lib/time-machine.js +214 -0
- package/package.json +1 -1
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
// Pull Request description and release notes generator (#379).
|
|
2
|
+
// Zero Cyrillic literals in lib/ per Issue #366.
|
|
3
|
+
|
|
4
|
+
import { inferScope } from './plan-generator.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Parse one-line git log output into structured commit records.
|
|
8
|
+
*
|
|
9
|
+
* @param {string} logText Raw git log output
|
|
10
|
+
* @returns {Array<{ sha: string, message: string }>}
|
|
11
|
+
*/
|
|
12
|
+
export function parseGitLog(logText) {
|
|
13
|
+
if (typeof logText !== 'string' || !logText.trim()) return []
|
|
14
|
+
const lines = logText.trim().split('\n')
|
|
15
|
+
const commits = []
|
|
16
|
+
|
|
17
|
+
for (const line of lines) {
|
|
18
|
+
const trimmed = line.trim()
|
|
19
|
+
if (!trimmed) continue
|
|
20
|
+
|
|
21
|
+
// Tab-delimited (git log --pretty=format:%h%x09%s) or space-delimited
|
|
22
|
+
let sha = ''
|
|
23
|
+
let message = ''
|
|
24
|
+
if (trimmed.includes('\t')) {
|
|
25
|
+
const parts = trimmed.split('\t')
|
|
26
|
+
sha = parts[0].trim()
|
|
27
|
+
message = parts.slice(1).join('\t').trim()
|
|
28
|
+
} else {
|
|
29
|
+
const match = trimmed.match(/^([a-f0-9]{7,40})\s+(.+)$/i)
|
|
30
|
+
if (match) {
|
|
31
|
+
sha = match[1]
|
|
32
|
+
message = match[2].trim()
|
|
33
|
+
} else {
|
|
34
|
+
message = trimmed
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (sha || message) {
|
|
39
|
+
commits.push({ sha, message })
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return commits
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Parse git diff --stat output into structured file and line counts.
|
|
48
|
+
*
|
|
49
|
+
* @param {string} statText Raw git diff --stat output
|
|
50
|
+
* @returns {{ filesChanged: number, additions: number, deletions: number, files: Array<{ path: string, changes: string }> }}
|
|
51
|
+
*/
|
|
52
|
+
export function parseDiffStat(statText) {
|
|
53
|
+
if (typeof statText !== 'string' || !statText.trim()) {
|
|
54
|
+
return { filesChanged: 0, additions: 0, deletions: 0, files: [] }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const lines = statText.trim().split('\n')
|
|
58
|
+
const files = []
|
|
59
|
+
let filesChanged = 0
|
|
60
|
+
let additions = 0
|
|
61
|
+
let deletions = 0
|
|
62
|
+
|
|
63
|
+
for (const line of lines) {
|
|
64
|
+
const trimmed = line.trim()
|
|
65
|
+
if (!trimmed) continue
|
|
66
|
+
|
|
67
|
+
// Summary line: "3 files changed, 45 insertions(+), 12 deletions(-)"
|
|
68
|
+
const summaryMatch = trimmed.match(/(\d+)\s+files?\s+changed(?:,\s+(\d+)\s+insertions?\(\+\))?(?:,\s+(\d+)\s+deletions?\(-\))?/)
|
|
69
|
+
if (summaryMatch) {
|
|
70
|
+
filesChanged = parseInt(summaryMatch[1], 10) || 0
|
|
71
|
+
additions = parseInt(summaryMatch[2], 10) || 0
|
|
72
|
+
deletions = parseInt(summaryMatch[3], 10) || 0
|
|
73
|
+
continue
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// File line: "lib/client.js | 14 +++++++-------"
|
|
77
|
+
const fileMatch = trimmed.match(/^(.+?)\s+\|\s+(\d+)\s*(.*)$/)
|
|
78
|
+
if (fileMatch) {
|
|
79
|
+
files.push({
|
|
80
|
+
path: fileMatch[1].trim(),
|
|
81
|
+
changes: fileMatch[2].trim(),
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (filesChanged === 0 && files.length > 0) {
|
|
87
|
+
filesChanged = files.length
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return { filesChanged, additions, deletions, files }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Infer conventional commit / PR type (feat, fix, refactor, perf, docs, chore).
|
|
95
|
+
*
|
|
96
|
+
* @param {object} task Task object
|
|
97
|
+
* @param {Array<{ sha: string, message: string }>} [commits]
|
|
98
|
+
* @returns {string} PR type
|
|
99
|
+
*/
|
|
100
|
+
export function inferPrType(task, commits = []) {
|
|
101
|
+
const title = String(task?.title ?? '').toLowerCase()
|
|
102
|
+
const labels = Array.isArray(task?.labels) ? task.labels.map((l) => String(l).toLowerCase()) : []
|
|
103
|
+
|
|
104
|
+
for (const label of labels) {
|
|
105
|
+
if (label.startsWith('type/')) {
|
|
106
|
+
const t = label.slice(5)
|
|
107
|
+
if (t === 'bug') return 'fix'
|
|
108
|
+
if (t === 'feature') return 'feat'
|
|
109
|
+
return t
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (title.startsWith('fix:') || title.includes('fix') || title.includes('bug')) return 'fix'
|
|
114
|
+
if (title.startsWith('refactor:') || title.includes('refactor')) return 'refactor'
|
|
115
|
+
if (title.startsWith('perf:') || title.includes('performance')) return 'perf'
|
|
116
|
+
if (title.startsWith('docs:') || title.includes('document')) return 'docs'
|
|
117
|
+
if (title.startsWith('chore:') || title.includes('cleanup')) return 'chore'
|
|
118
|
+
|
|
119
|
+
for (const c of commits) {
|
|
120
|
+
const msg = String(c?.message ?? '').toLowerCase()
|
|
121
|
+
if (msg.startsWith('fix')) return 'fix'
|
|
122
|
+
if (msg.startsWith('refactor')) return 'refactor'
|
|
123
|
+
if (msg.startsWith('perf')) return 'perf'
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return 'feat'
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Generate canonical PR title according to conventional commits format.
|
|
131
|
+
*
|
|
132
|
+
* @param {object} task Task object
|
|
133
|
+
* @param {Array<{ sha: string, message: string }>} [commits]
|
|
134
|
+
* @returns {string} PR title
|
|
135
|
+
*/
|
|
136
|
+
export function generatePrTitle(task, commits = []) {
|
|
137
|
+
const type = inferPrType(task, commits)
|
|
138
|
+
const scope = inferScope(task)
|
|
139
|
+
let cleanTitle = String(task?.title ?? '').trim()
|
|
140
|
+
|
|
141
|
+
// Strip leading prefixes like "L: Feature:", "M: Fix:", "feat:", etc.
|
|
142
|
+
cleanTitle = cleanTitle.replace(/^[A-Z]:\s*(Feature|Bug|Fix|Refactor|Task):\s*/i, '')
|
|
143
|
+
cleanTitle = cleanTitle.replace(/^(feat|fix|refactor|perf|docs|chore)(\([^)]+\))?:\s*/i, '')
|
|
144
|
+
|
|
145
|
+
const issueNum = typeof task?.issueNumber === 'number' ? task.issueNumber : null
|
|
146
|
+
const issueRef = issueNum ? ` (#${issueNum})` : ''
|
|
147
|
+
|
|
148
|
+
return `${type}(${scope}): ${cleanTitle}${issueRef}`
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Generate canonical, structured Pull Request description markdown (#379).
|
|
153
|
+
*
|
|
154
|
+
* @param {object} params
|
|
155
|
+
* @param {object} params.task Task record
|
|
156
|
+
* @param {Array<{ sha: string, message: string }>} [params.commits]
|
|
157
|
+
* @param {object} [params.diffStats]
|
|
158
|
+
* @param {object} [params.verification]
|
|
159
|
+
* @returns {string} Complete Markdown description for PR
|
|
160
|
+
*/
|
|
161
|
+
export function generatePrDescription({ task, commits = [], diffStats = null, verification = null }) {
|
|
162
|
+
const title = String(task?.title ?? '').trim()
|
|
163
|
+
const body = String(task?.body ?? '').trim()
|
|
164
|
+
const issueNum = typeof task?.issueNumber === 'number' ? task.issueNumber : null
|
|
165
|
+
const issueRef = issueNum ? `Closes #${issueNum}` : ''
|
|
166
|
+
|
|
167
|
+
const lines = []
|
|
168
|
+
|
|
169
|
+
// 1. Problem statement
|
|
170
|
+
lines.push('### Problem')
|
|
171
|
+
if (body) {
|
|
172
|
+
lines.push(body)
|
|
173
|
+
} else {
|
|
174
|
+
lines.push(`Implementation required for: ${title}`)
|
|
175
|
+
}
|
|
176
|
+
lines.push('')
|
|
177
|
+
|
|
178
|
+
// 2. Solution description
|
|
179
|
+
lines.push('### Solution')
|
|
180
|
+
if (task?.spec) {
|
|
181
|
+
// Extract key sections or summary from spec if present
|
|
182
|
+
const specLines = task.spec.split('\n')
|
|
183
|
+
const solutionLines = specLines.filter((l) => !l.startsWith('# Technical Specification') && !l.startsWith('## 1.'))
|
|
184
|
+
lines.push(solutionLines.slice(0, 15).join('\n').trim())
|
|
185
|
+
} else if (Array.isArray(task?.plan?.items) && task.plan.items.length > 0) {
|
|
186
|
+
lines.push('Implemented following plan:')
|
|
187
|
+
for (const item of task.plan.items) {
|
|
188
|
+
lines.push(`- ${item}`)
|
|
189
|
+
}
|
|
190
|
+
} else {
|
|
191
|
+
lines.push(`- Resolved requirements specified in ${title}`)
|
|
192
|
+
}
|
|
193
|
+
lines.push('')
|
|
194
|
+
|
|
195
|
+
// 3. Changed components & diff statistics
|
|
196
|
+
if (diffStats && (diffStats.files?.length > 0 || diffStats.filesChanged > 0)) {
|
|
197
|
+
lines.push('### Changed Components & Files')
|
|
198
|
+
lines.push(`- Total files changed: ${diffStats.filesChanged || diffStats.files.length}`)
|
|
199
|
+
if (diffStats.additions || diffStats.deletions) {
|
|
200
|
+
lines.push(`- Changes: +${diffStats.additions} / -${diffStats.deletions}`)
|
|
201
|
+
}
|
|
202
|
+
if (Array.isArray(diffStats.files) && diffStats.files.length > 0) {
|
|
203
|
+
for (const f of diffStats.files.slice(0, 12)) {
|
|
204
|
+
lines.push(` - \`${f.path}\``)
|
|
205
|
+
}
|
|
206
|
+
if (diffStats.files.length > 12) {
|
|
207
|
+
lines.push(` - ... and ${diffStats.files.length - 12} more files`)
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
lines.push('')
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// 4. Commits list
|
|
214
|
+
if (Array.isArray(commits) && commits.length > 0) {
|
|
215
|
+
lines.push('### Commits')
|
|
216
|
+
for (const c of commits) {
|
|
217
|
+
const shaShort = c.sha ? `\`${c.sha.slice(0, 7)}\` ` : ''
|
|
218
|
+
lines.push(`- ${shaShort}${c.message}`)
|
|
219
|
+
}
|
|
220
|
+
lines.push('')
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// 5. Verification status
|
|
224
|
+
const ver = verification || task?.report?.verification
|
|
225
|
+
if (ver) {
|
|
226
|
+
lines.push('### Verification Results')
|
|
227
|
+
const statusText = ver.status === 'passed' ? '✓ Tests Passed' : (ver.status === 'failed' ? '✗ Tests Failed' : 'Pending')
|
|
228
|
+
lines.push(`- Status: **${statusText}**`)
|
|
229
|
+
if (ver.command) {
|
|
230
|
+
lines.push(`- Command: \`${ver.command}\``)
|
|
231
|
+
}
|
|
232
|
+
if (ver.verifiedAt) {
|
|
233
|
+
lines.push(`- Timestamp: ${new Date(ver.verifiedAt).toISOString()}`)
|
|
234
|
+
}
|
|
235
|
+
lines.push('')
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// 6. Issue reference closure
|
|
239
|
+
if (issueRef) {
|
|
240
|
+
lines.push(issueRef)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return lines.join('\n').trim()
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Generate release notes entry for CHANGELOG.md.
|
|
248
|
+
*
|
|
249
|
+
* @param {object} params
|
|
250
|
+
* @param {object} params.task
|
|
251
|
+
* @param {Array<{ sha: string, message: string }>} [params.commits]
|
|
252
|
+
* @returns {string} Formatted release notes bullet
|
|
253
|
+
*/
|
|
254
|
+
export function generateReleaseNotes({ task, commits = [] }) {
|
|
255
|
+
const title = String(task?.title ?? '').trim()
|
|
256
|
+
const issueNum = typeof task?.issueNumber === 'number' ? ` (#${task.issueNumber})` : ''
|
|
257
|
+
const cleanTitle = title.replace(/^[A-Z]:\s*(Feature|Bug|Fix|Refactor|Task):\s*/i, '')
|
|
258
|
+
|
|
259
|
+
const lines = [`- **${cleanTitle}${issueNum}**:`]
|
|
260
|
+
|
|
261
|
+
if (Array.isArray(commits) && commits.length > 0) {
|
|
262
|
+
for (const c of commits.slice(0, 5)) {
|
|
263
|
+
lines.push(` - ${c.message}`)
|
|
264
|
+
}
|
|
265
|
+
} else {
|
|
266
|
+
lines.push(` - Implementation and automated verification complete.`)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return lines.join('\n')
|
|
270
|
+
}
|
package/lib/routes/board.js
CHANGED
|
@@ -4,6 +4,7 @@ import { columnStats, doneWithin, stale } from '../metrics.js'
|
|
|
4
4
|
import { isStale } from '../time.js'
|
|
5
5
|
import { facetsOf, facetsOfTask, groupByRepo, sortByLife } from '../filters.js'
|
|
6
6
|
import { computeNextRun } from '../cron.js'
|
|
7
|
+
import { computeBlocksMap, checkTaskBlocked } from '../dependencies.js'
|
|
7
8
|
|
|
8
9
|
export function withPlan(task, liveOf, config, now, sameSession) {
|
|
9
10
|
const plan = parsePlan(task.plan)
|
|
@@ -74,12 +75,15 @@ export function buildBoard({ store, config, board = 'main', repo, liveOf, sync,
|
|
|
74
75
|
if (!bySession.has(id)) bySession.set(id, [])
|
|
75
76
|
bySession.get(id).push(task)
|
|
76
77
|
}
|
|
78
|
+
const blocksMap = computeBlocksMap(raw)
|
|
77
79
|
const tasks = orderForBoard(
|
|
78
80
|
raw.map((task) => {
|
|
79
81
|
const enriched = withPlan(task, liveOf, config, now, bySession.get(task.sessionId) ?? [])
|
|
80
82
|
if (task.isEpic) {
|
|
81
83
|
enriched.subtasksCount = store.countSubtasks(task.id)
|
|
82
84
|
}
|
|
85
|
+
enriched.blocks = blocksMap[task.id] || []
|
|
86
|
+
enriched.isBlocked = checkTaskBlocked(task, store).isBlocked
|
|
83
87
|
return enriched
|
|
84
88
|
}),
|
|
85
89
|
columnsOf(store.boardKind(board)),
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
import { isTimeMachineAvailable, listTaskSnapshots, createCheckpointSnapshot, rollbackToSnapshot } from '../time-machine.js'
|
|
2
|
+
import { generateTechnicalSpec, parseSpecMarkdown } from '../plan-generator.js'
|
|
3
|
+
import { generatePrTitle, generatePrDescription, generateReleaseNotes } from '../pr-generator.js'
|
|
4
|
+
import { getTaskCommits, getTaskDiffInspector } from '../worktree.js'
|
|
5
|
+
|
|
1
6
|
/**
|
|
2
7
|
* Extended actions for task route handlers (comments, reports, checklist, triage, subtasks, PR, nudge).
|
|
3
8
|
* Zero Cyrillic in lib/ according to Issue #366.
|
|
@@ -249,7 +254,7 @@ export async function handleCreateTaskPr({ store, gitea, task, input }) {
|
|
|
249
254
|
if (!head) return { status: 400, body: { error: 'branch-required' } }
|
|
250
255
|
const base = String(input?.base || 'main').trim()
|
|
251
256
|
const title = String(input?.title || task.title || `PR: ${task.title}`).trim()
|
|
252
|
-
const body = String(input?.body || (task.issueNumber ? `Closes #${task.issueNumber}` : '')).trim()
|
|
257
|
+
const body = String(input?.body || task.prDescription || (task.issueNumber ? `Closes #${task.issueNumber}` : '')).trim()
|
|
253
258
|
|
|
254
259
|
try {
|
|
255
260
|
const pr = await gitea.createPullRequest({
|
|
@@ -343,3 +348,157 @@ export async function handleTaskNudge({ store, id, prompt, agents, createMessage
|
|
|
343
348
|
},
|
|
344
349
|
}
|
|
345
350
|
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Handle listing or creating snapshots via dsh-time-machine (#377).
|
|
354
|
+
*/
|
|
355
|
+
export async function handleTaskSnapshot({ ctx, store, id, method, body, config, env, cwdOf, logger }) {
|
|
356
|
+
const task = store.getTask(id)
|
|
357
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
358
|
+
|
|
359
|
+
if (method === 'GET') {
|
|
360
|
+
const available = isTimeMachineAvailable(ctx)
|
|
361
|
+
const snapshots = available ? await listTaskSnapshots({ ctx, task }) : []
|
|
362
|
+
return {
|
|
363
|
+
status: 200,
|
|
364
|
+
body: {
|
|
365
|
+
available,
|
|
366
|
+
inceptionSnapshotId: task.inceptionSnapshotId || '',
|
|
367
|
+
snapshots,
|
|
368
|
+
},
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (method === 'POST') {
|
|
373
|
+
const label = typeof body?.label === 'string' ? body.label.trim() : ''
|
|
374
|
+
const res = await createCheckpointSnapshot({ ctx, task, label, config, env, cwdOf, logger })
|
|
375
|
+
if (!res.available) {
|
|
376
|
+
return {
|
|
377
|
+
status: 503,
|
|
378
|
+
body: {
|
|
379
|
+
error: 'time-machine-unavailable',
|
|
380
|
+
message: 'Plugin @goodandready/dsh-time-machine is not installed or active in this profile.',
|
|
381
|
+
},
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
if (!res.created) {
|
|
385
|
+
return { status: 500, body: { error: 'snapshot-failed', message: res.error } }
|
|
386
|
+
}
|
|
387
|
+
return { status: 201, body: { success: true, snapshot: res.snapshot } }
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
return { status: 405, body: { error: 'method-not-allowed' } }
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Handle rolling back task worktree to inception or specified snapshot (#377).
|
|
395
|
+
*/
|
|
396
|
+
export async function handleTaskRollbackSnapshot({ ctx, store, id, body, config, env, cwdOf, logger, eventHub }) {
|
|
397
|
+
const task = store.getTask(id)
|
|
398
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
399
|
+
|
|
400
|
+
const snapshotId = typeof body?.snapshotId === 'string' ? body.snapshotId.trim() : task.inceptionSnapshotId
|
|
401
|
+
const res = await rollbackToSnapshot({ ctx, task, snapshotId, config, env, cwdOf, logger })
|
|
402
|
+
if (!res.available) {
|
|
403
|
+
return { status: 503, body: { error: 'time-machine-unavailable', message: res.message } }
|
|
404
|
+
}
|
|
405
|
+
if (!res.rolledBack) {
|
|
406
|
+
return { status: 400, body: { error: res.error, message: res.message } }
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (eventHub && typeof eventHub.broadcast === 'function') {
|
|
410
|
+
eventHub.broadcast('task:change', { taskId: id, action: 'rollback', snapshotId: res.snapshotId })
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
return {
|
|
414
|
+
status: 200,
|
|
415
|
+
body: {
|
|
416
|
+
success: true,
|
|
417
|
+
snapshotId: res.snapshotId,
|
|
418
|
+
result: res.result,
|
|
419
|
+
},
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Generate technical specification & plan for task (#376).
|
|
426
|
+
*/
|
|
427
|
+
export async function handleTaskGeneratePlan({ store, id, body, eventHub }) {
|
|
428
|
+
const task = store.getTask(id)
|
|
429
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
430
|
+
|
|
431
|
+
const result = generateTechnicalSpec(task, body?.options || {})
|
|
432
|
+
const updated = store.updateTask(id, {
|
|
433
|
+
spec: result.spec,
|
|
434
|
+
plan: { items: result.items, current: 0 },
|
|
435
|
+
})
|
|
436
|
+
|
|
437
|
+
if (eventHub && typeof eventHub.broadcast === 'function') {
|
|
438
|
+
eventHub.broadcast('task:change', { taskId: id, action: 'plan-generated', spec: result.spec })
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
return {
|
|
442
|
+
status: 200,
|
|
443
|
+
body: {
|
|
444
|
+
success: true,
|
|
445
|
+
spec: result.spec,
|
|
446
|
+
items: result.items,
|
|
447
|
+
summary: result.summary,
|
|
448
|
+
task: updated,
|
|
449
|
+
},
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Handle PR description generation and saving (#379).
|
|
455
|
+
*/
|
|
456
|
+
export async function handleTaskPrDescription({ store, id, method, body, gitRunner }) {
|
|
457
|
+
const task = store.getTask(id)
|
|
458
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
459
|
+
|
|
460
|
+
if (method === 'POST' && typeof body?.prDescription === 'string') {
|
|
461
|
+
const updated = store.updateTask(id, { prDescription: body.prDescription })
|
|
462
|
+
return { status: 200, body: { success: true, prDescription: body.prDescription, task: updated } }
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
let commits = []
|
|
466
|
+
let diffStats = null
|
|
467
|
+
|
|
468
|
+
if (task.worktree) {
|
|
469
|
+
try {
|
|
470
|
+
commits = await getTaskCommits({ worktreePath: task.worktree, gitRunner })
|
|
471
|
+
} catch {
|
|
472
|
+
commits = []
|
|
473
|
+
}
|
|
474
|
+
try {
|
|
475
|
+
const inspector = await getTaskDiffInspector({ worktreePath: task.worktree, gitRunner })
|
|
476
|
+
if (inspector) {
|
|
477
|
+
diffStats = {
|
|
478
|
+
filesChanged: inspector.filesChanged || (inspector.files || []).length,
|
|
479
|
+
files: (inspector.files || []).map((f) => ({ path: f.file || f.path || f, changes: '' })),
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
} catch {
|
|
483
|
+
diffStats = null
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const generatedTitle = generatePrTitle(task, commits)
|
|
488
|
+
const generatedDescription = generatePrDescription({ task, commits, diffStats })
|
|
489
|
+
const releaseNotes = generateReleaseNotes({ task, commits })
|
|
490
|
+
|
|
491
|
+
return {
|
|
492
|
+
status: 200,
|
|
493
|
+
body: {
|
|
494
|
+
success: true,
|
|
495
|
+
title: generatedTitle,
|
|
496
|
+
prDescription: task.prDescription || generatedDescription,
|
|
497
|
+
generatedDescription,
|
|
498
|
+
releaseNotes,
|
|
499
|
+
commits,
|
|
500
|
+
diffStats,
|
|
501
|
+
},
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
package/lib/routes/task.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { parseBody } from '../markdown.js'
|
|
2
2
|
import { columnsOf, wipLimitField } from '../config.js'
|
|
3
3
|
import { withPlan } from './board.js'
|
|
4
|
+
import { checkMoveAllowed, validateDependencies, findUnblockedDependents } from '../dependencies.js'
|
|
4
5
|
|
|
5
6
|
export function parseTaskPath(pathname) {
|
|
6
7
|
const m = /^\/dsh-kanban\/task\/([^/]+)(?:\/([a-z-]+))?\/?$/.exec(pathname ?? '')
|
|
@@ -23,6 +24,17 @@ export function applyMove({ store, config, id, column, beforeId, afterId, source
|
|
|
23
24
|
if (!allowed.includes(column)) {
|
|
24
25
|
return { error: 'invalid-column', status: 400 }
|
|
25
26
|
}
|
|
27
|
+
if (column === 'in-progress' && before.column !== 'in-progress') {
|
|
28
|
+
const moveCheck = checkMoveAllowed(before, column, store)
|
|
29
|
+
if (!moveCheck.allowed) {
|
|
30
|
+
return {
|
|
31
|
+
error: 'task-blocked',
|
|
32
|
+
status: 400,
|
|
33
|
+
message: moveCheck.reason,
|
|
34
|
+
pendingBlockers: moveCheck.pendingBlockers,
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
26
38
|
}
|
|
27
39
|
const task = store.moveTask(id, { column, beforeId, afterId })
|
|
28
40
|
if (task.column !== before.column) {
|
|
@@ -32,7 +44,10 @@ export function applyMove({ store, config, id, column, beforeId, afterId, source
|
|
|
32
44
|
}
|
|
33
45
|
const limit = wipLimitField(config, task.column)
|
|
34
46
|
const count = store.countInColumn(task.board, task.column)
|
|
35
|
-
|
|
47
|
+
const unblocked = (task.column === 'done' && before.column !== 'done')
|
|
48
|
+
? findUnblockedDependents(id, store, () => store.listTasks({ board: task.board }))
|
|
49
|
+
: []
|
|
50
|
+
return { task, overLimit: limit !== undefined && count > limit, unblocked }
|
|
36
51
|
}
|
|
37
52
|
|
|
38
53
|
/** Create local board task (not linked to Gitea). */
|
|
@@ -96,6 +111,17 @@ export function updateTask({ store, id, input }) {
|
|
|
96
111
|
const val = input.forge.trim().toLowerCase()
|
|
97
112
|
if (val === 'github' || val === 'gitea') patch.forge = val
|
|
98
113
|
}
|
|
114
|
+
if (Array.isArray(input?.blockedBy)) {
|
|
115
|
+
const newBlockedBy = input.blockedBy.filter((b) => typeof b === 'string' && b.trim() !== '')
|
|
116
|
+
const check = validateDependencies(id, newBlockedBy, store)
|
|
117
|
+
if (!check.valid) {
|
|
118
|
+
return { error: 'circular-dependency', status: 400, message: check.error }
|
|
119
|
+
}
|
|
120
|
+
patch.blockedBy = newBlockedBy
|
|
121
|
+
}
|
|
122
|
+
if (typeof input?.inceptionSnapshotId === 'string') {
|
|
123
|
+
patch.inceptionSnapshotId = input.inceptionSnapshotId.trim()
|
|
124
|
+
}
|
|
99
125
|
return { task: store.updateTask(id, patch) }
|
|
100
126
|
}
|
|
101
127
|
|
package/lib/routes.js
CHANGED
|
@@ -30,6 +30,8 @@ import {
|
|
|
30
30
|
handleToggleChecklistItem,
|
|
31
31
|
} from '../routes.js'
|
|
32
32
|
import { verifyAndRecordGate } from '../verification.js'
|
|
33
|
+
import { handleTaskSnapshot, handleTaskRollbackSnapshot, handleTaskGeneratePlan, handleTaskPrDescription } from '../routes.js'
|
|
34
|
+
import { createInceptionSnapshot } from '../time-machine.js'
|
|
33
35
|
import { runTask, queueTask, unqueueTask, resolveModel, buildStartMessage, PERMISSION_RANKS } from '../launcher.js'
|
|
34
36
|
import { dispatchMove, stopWork, MOVE_DETAIL, STOP_DETAIL } from '../commands.js'
|
|
35
37
|
import { checkWorktreeDirty } from '../worktree.js'
|
|
@@ -257,6 +259,25 @@ export function createTaskRouteHandler(deps) {
|
|
|
257
259
|
out.dirtyFiles = dirty.files
|
|
258
260
|
}
|
|
259
261
|
}
|
|
262
|
+
if (out.task.column === 'in-progress' && was?.column !== 'in-progress') {
|
|
263
|
+
const cfg = getConfig()
|
|
264
|
+
if (cfg?.timeMachineEnabled !== false && cfg?.timeMachineAutoSnapshot !== false) {
|
|
265
|
+
createInceptionSnapshot({
|
|
266
|
+
ctx,
|
|
267
|
+
task: out.task,
|
|
268
|
+
config: cfg,
|
|
269
|
+
env: process.env,
|
|
270
|
+
cwdOf: () => rootOf(cfg, () => process.cwd()).path,
|
|
271
|
+
store,
|
|
272
|
+
logger: ctx.logger,
|
|
273
|
+
}).catch(() => {})
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
if (Array.isArray(out.unblocked) && out.unblocked.length > 0) {
|
|
277
|
+
for (const dep of out.unblocked) {
|
|
278
|
+
eventHub.broadcast('task:change', { taskId: dep.id, action: 'unblocked', task: dep })
|
|
279
|
+
}
|
|
280
|
+
}
|
|
260
281
|
}
|
|
261
282
|
reply(res, out)
|
|
262
283
|
return
|
|
@@ -400,6 +421,36 @@ export function createTaskRouteHandler(deps) {
|
|
|
400
421
|
writeJson(res, resObj.status, resObj.body)
|
|
401
422
|
return
|
|
402
423
|
}
|
|
424
|
+
if ((req.method === 'GET' || req.method === 'POST') && action === 'snapshot') {
|
|
425
|
+
const cfg = getConfig()
|
|
426
|
+
const resObj = await handleTaskSnapshot({
|
|
427
|
+
ctx, store, id, method: req.method, body: input, config: cfg,
|
|
428
|
+
env: process.env, cwdOf: () => rootOf(cfg, () => process.cwd()).path, logger: ctx.logger,
|
|
429
|
+
})
|
|
430
|
+
writeJson(res, resObj.status, resObj.body)
|
|
431
|
+
return
|
|
432
|
+
}
|
|
433
|
+
if (req.method === 'POST' && action === 'rollback-snapshot') {
|
|
434
|
+
const cfg = getConfig()
|
|
435
|
+
const resObj = await handleTaskRollbackSnapshot({
|
|
436
|
+
ctx, store, id, body: input, config: cfg,
|
|
437
|
+
env: process.env, cwdOf: () => rootOf(cfg, () => process.cwd()).path, logger: ctx.logger, eventHub,
|
|
438
|
+
})
|
|
439
|
+
writeJson(res, resObj.status, resObj.body)
|
|
440
|
+
return
|
|
441
|
+
}
|
|
442
|
+
if (req.method === 'POST' && action === 'generate-plan') {
|
|
443
|
+
const resObj = await handleTaskGeneratePlan({ store, id, body: input, eventHub })
|
|
444
|
+
writeJson(res, resObj.status, resObj.body)
|
|
445
|
+
return
|
|
446
|
+
}
|
|
447
|
+
if ((req.method === 'GET' || req.method === 'POST') && action === 'pr-description') {
|
|
448
|
+
const cfg = getConfig()
|
|
449
|
+
const gitRunner = cfg.remoteMode ? createRemoteGitRunner(cfg) : undefined
|
|
450
|
+
const resObj = await handleTaskPrDescription({ store, id, method: req.method, body: input, gitRunner })
|
|
451
|
+
writeJson(res, resObj.status, resObj.body)
|
|
452
|
+
return
|
|
453
|
+
}
|
|
403
454
|
if (req.method === 'POST' && action === 'resume') {
|
|
404
455
|
watchdog?.resumeTask?.({ store, taskId: id, eventHub })
|
|
405
456
|
const task = store.getTask(id)
|
package/lib/store.js
CHANGED
|
@@ -79,6 +79,10 @@ CREATE TABLE IF NOT EXISTS tasks (
|
|
|
79
79
|
stalled INTEGER NOT NULL DEFAULT 0,
|
|
80
80
|
stalledReason TEXT NOT NULL DEFAULT '',
|
|
81
81
|
stalledAt INTEGER NOT NULL DEFAULT 0,
|
|
82
|
+
blockedBy TEXT NOT NULL DEFAULT '[]',
|
|
83
|
+
inceptionSnapshotId TEXT NOT NULL DEFAULT '',
|
|
84
|
+
spec TEXT NOT NULL DEFAULT '',
|
|
85
|
+
prDescription TEXT NOT NULL DEFAULT '',
|
|
82
86
|
updatedAt INTEGER NOT NULL
|
|
83
87
|
);
|
|
84
88
|
CREATE INDEX IF NOT EXISTS tasks_board_col ON tasks(board, col, position);
|
|
@@ -114,6 +118,7 @@ const WRITABLE = [
|
|
|
114
118
|
'verificationCommand', 'verificationStatus',
|
|
115
119
|
'costMetrics', 'costLimitExceeded',
|
|
116
120
|
'stalled', 'stalledReason', 'stalledAt',
|
|
121
|
+
'blockedBy', 'inceptionSnapshotId', 'spec', 'prDescription',
|
|
117
122
|
]
|
|
118
123
|
|
|
119
124
|
/** Valid transition sources; unexpected values are rejected to preserve log integrity. */
|
|
@@ -121,7 +126,7 @@ const SOURCES = new Set(['manual', 'session', 'gitea', 'github', 'tool', 'workfl
|
|
|
121
126
|
|
|
122
127
|
function rowToTask(row) {
|
|
123
128
|
if (row === undefined) return undefined
|
|
124
|
-
const { col, labels, waiting, labelColors, runs, checklist, report, comments, rejectReason, rejectedAt, isEpic, parentId, stalled, ...rest } = row
|
|
129
|
+
const { col, labels, waiting, labelColors, runs, checklist, report, comments, rejectReason, rejectedAt, isEpic, parentId, stalled, blockedBy, inceptionSnapshotId, spec, prDescription, ...rest } = row
|
|
125
130
|
let parsedChecklist = []
|
|
126
131
|
try {
|
|
127
132
|
const value = JSON.parse(checklist ?? '[]')
|
|
@@ -177,6 +182,15 @@ function rowToTask(row) {
|
|
|
177
182
|
return rest.costMetrics && typeof rest.costMetrics === 'object' ? rest.costMetrics : null;
|
|
178
183
|
})(),
|
|
179
184
|
costLimitExceeded: rest.costLimitExceeded === 1 || rest.costLimitExceeded === true,
|
|
185
|
+
blockedBy: (() => {
|
|
186
|
+
try {
|
|
187
|
+
const val = JSON.parse(blockedBy ?? '[]')
|
|
188
|
+
return Array.isArray(val) ? val.filter((x) => typeof x === 'string') : []
|
|
189
|
+
} catch { return [] }
|
|
190
|
+
})(),
|
|
191
|
+
inceptionSnapshotId: typeof inceptionSnapshotId === 'string' ? inceptionSnapshotId : (typeof rest.inceptionSnapshotId === 'string' ? rest.inceptionSnapshotId : ''),
|
|
192
|
+
spec: typeof spec === 'string' ? spec : (typeof rest.spec === 'string' ? rest.spec : ''),
|
|
193
|
+
prDescription: typeof prDescription === 'string' ? prDescription : (typeof rest.prDescription === 'string' ? rest.prDescription : ''),
|
|
180
194
|
}
|
|
181
195
|
}
|
|
182
196
|
|
|
@@ -272,6 +286,10 @@ export function openStore({ dir }) {
|
|
|
272
286
|
['stalled', 'INTEGER NOT NULL DEFAULT 0'],
|
|
273
287
|
['stalledReason', "TEXT NOT NULL DEFAULT ''"],
|
|
274
288
|
['stalledAt', 'INTEGER NOT NULL DEFAULT 0'],
|
|
289
|
+
['blockedBy', "TEXT NOT NULL DEFAULT '[]'"],
|
|
290
|
+
['inceptionSnapshotId', "TEXT NOT NULL DEFAULT ''"],
|
|
291
|
+
['spec', "TEXT NOT NULL DEFAULT ''"],
|
|
292
|
+
['prDescription', "TEXT NOT NULL DEFAULT ''"],
|
|
275
293
|
]) {
|
|
276
294
|
try { db.exec(`ALTER TABLE tasks ADD COLUMN ${column} ${definition}`) } catch { /* column already exists */ }
|
|
277
295
|
}
|
|
@@ -602,7 +620,8 @@ export function openStore({ dir }) {
|
|
|
602
620
|
for (const key of WRITABLE) {
|
|
603
621
|
if (!Object.hasOwn(patch, key)) continue
|
|
604
622
|
fields.push(`${key} = ?`)
|
|
605
|
-
if (key === '
|
|
623
|
+
if (key === 'plan') values.push(typeof patch[key] === 'string' ? patch[key] : JSON.stringify(patch[key] ?? { items: [], current: 0 }))
|
|
624
|
+
else if (key === 'labels') values.push(JSON.stringify(patch[key] ?? []))
|
|
606
625
|
else if (key === 'labelColors') values.push(JSON.stringify(patch[key] ?? {}))
|
|
607
626
|
else if (key === 'runs') values.push(typeof patch[key] === 'string' ? patch[key] : JSON.stringify(patch[key] ?? []))
|
|
608
627
|
else if (key === 'checklist') values.push(typeof patch[key] === 'string' ? patch[key] : JSON.stringify(patch[key] ?? []))
|
|
@@ -614,6 +633,10 @@ export function openStore({ dir }) {
|
|
|
614
633
|
else if (key === 'syncedAt') values.push(Number(patch[key]) || 0)
|
|
615
634
|
else if (key === 'isEpic') values.push(patch[key] ? 1 : 0)
|
|
616
635
|
else if (key === 'parentId') values.push(String(patch[key] ?? ''))
|
|
636
|
+
else if (key === 'blockedBy') values.push(Array.isArray(patch[key]) ? JSON.stringify(patch[key]) : (typeof patch[key] === 'string' ? patch[key] : '[]'))
|
|
637
|
+
else if (key === 'inceptionSnapshotId') values.push(String(patch[key] ?? ''))
|
|
638
|
+
else if (key === 'spec') values.push(String(patch[key] ?? ''))
|
|
639
|
+
else if (key === 'prDescription') values.push(String(patch[key] ?? ''))
|
|
617
640
|
else values.push(patch[key] ?? null)
|
|
618
641
|
}
|
|
619
642
|
if (fields.length > 0) {
|