feat(ST-004): emit one task per backlog sub-bullet, not per story
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1abbd4e5e4
commit
a5f81fce70
2 changed files with 87 additions and 30 deletions
|
|
@ -1,13 +1,19 @@
|
||||||
import { readFile } from 'node:fs/promises'
|
import { readFile } from 'node:fs/promises'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
|
|
||||||
|
export type ParsedTask = {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
sort_order: number
|
||||||
|
}
|
||||||
|
|
||||||
export type ParsedStory = {
|
export type ParsedStory = {
|
||||||
ref: string
|
ref: string
|
||||||
title: string
|
title: string
|
||||||
description: string
|
|
||||||
acceptance_criteria: string
|
acceptance_criteria: string
|
||||||
status: 'DONE' | 'OPEN'
|
status: 'DONE' | 'OPEN'
|
||||||
sort_order: number
|
sort_order: number
|
||||||
|
tasks: ParsedTask[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ParsedMilestone = {
|
export type ParsedMilestone = {
|
||||||
|
|
@ -25,6 +31,7 @@ const TASK_BULLET = /^- \[(x| )\] \*\*(ST-\d+)\*\*\s+(.+?)\s*$/
|
||||||
const SUB_BULLET = /^ {2}- (.+?)\s*$/
|
const SUB_BULLET = /^ {2}- (.+?)\s*$/
|
||||||
const NESTED_LINE = /^ {4,}\S/
|
const NESTED_LINE = /^ {4,}\S/
|
||||||
const SECTION_BREAK = /^---\s*$/
|
const SECTION_BREAK = /^---\s*$/
|
||||||
|
const BOLD_PREFIX = /^\*\*([^*]+?)\*\*\s*:?\s*(.*)$/
|
||||||
|
|
||||||
const MILESTONE_PRIORITY: Record<string, 1 | 2 | 3 | 4> = {
|
const MILESTONE_PRIORITY: Record<string, 1 | 2 | 3 | 4> = {
|
||||||
M0: 1,
|
M0: 1,
|
||||||
|
|
@ -61,17 +68,59 @@ const MILESTONE_SPRINT_STATUS: Record<string, ParsedMilestone['sprint_status']>
|
||||||
|
|
||||||
const KNOWN_KEYS = Object.keys(MILESTONE_PRIORITY)
|
const KNOWN_KEYS = Object.keys(MILESTONE_PRIORITY)
|
||||||
|
|
||||||
|
type SubBullet = {
|
||||||
|
headLine: string
|
||||||
|
nestedLines: string[]
|
||||||
|
isAcceptance: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTask(b: SubBullet, sort_order: number): ParsedTask {
|
||||||
|
const nested = b.nestedLines.join('\n')
|
||||||
|
const m = b.headLine.match(BOLD_PREFIX)
|
||||||
|
if (m) {
|
||||||
|
const title = m[1].replace(/`/g, '').replace(/:$/, '').trim()
|
||||||
|
const description = [m[2].trim(), nested].filter(Boolean).join('\n').trim()
|
||||||
|
return { title, description, sort_order }
|
||||||
|
}
|
||||||
|
// No bold prefix: derive a short title from the first clause; full bullet stays in description.
|
||||||
|
const head = b.headLine.replace(/`/g, '').trim()
|
||||||
|
const firstClause = head.split(/[;.]\s/, 1)[0].trim()
|
||||||
|
const title = firstClause.length > 80 ? firstClause.slice(0, 79).trim() + '…' : firstClause
|
||||||
|
const description = [b.headLine, nested].filter(Boolean).join('\n').trim()
|
||||||
|
return { title, description, sort_order }
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAcceptance(b: SubBullet): string {
|
||||||
|
const head = b.headLine.replace(/^Done when:\s*/i, '').trim()
|
||||||
|
const nested = b.nestedLines.join('\n').trim()
|
||||||
|
return [head, nested].filter(Boolean).join('\n').trim()
|
||||||
|
}
|
||||||
|
|
||||||
export async function loadBacklog(repoRoot: string): Promise<ParsedMilestone[]> {
|
export async function loadBacklog(repoRoot: string): Promise<ParsedMilestone[]> {
|
||||||
const file = path.join(repoRoot, 'docs/scrum4me-backlog.md')
|
const file = path.join(repoRoot, 'docs/scrum4me-backlog.md')
|
||||||
const md = await readFile(file, 'utf8')
|
const md = await readFile(file, 'utf8')
|
||||||
|
|
||||||
const milestones: ParsedMilestone[] = []
|
const milestones: ParsedMilestone[] = []
|
||||||
let current: ParsedMilestone | null = null
|
let current: ParsedMilestone | null = null
|
||||||
let pending: { story: ParsedStory; bodyLines: string[] } | null = null
|
let pending: {
|
||||||
|
story: ParsedStory
|
||||||
|
bullets: SubBullet[]
|
||||||
|
activeBullet: SubBullet | null
|
||||||
|
} | null = null
|
||||||
|
|
||||||
const flushPending = () => {
|
const flushPending = () => {
|
||||||
if (!pending) return
|
if (!pending) return
|
||||||
pending.story.description = pending.bodyLines.join('\n').trim()
|
if (pending.activeBullet) {
|
||||||
|
pending.bullets.push(pending.activeBullet)
|
||||||
|
pending.activeBullet = null
|
||||||
|
}
|
||||||
|
const taskBullets = pending.bullets.filter((b) => !b.isAcceptance)
|
||||||
|
const acceptanceBullets = pending.bullets.filter((b) => b.isAcceptance)
|
||||||
|
pending.story.tasks = taskBullets.map((b, i) => buildTask(b, i + 1))
|
||||||
|
pending.story.acceptance_criteria = acceptanceBullets
|
||||||
|
.map(buildAcceptance)
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n')
|
||||||
pending = null
|
pending = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -106,13 +155,13 @@ export async function loadBacklog(repoRoot: string): Promise<ParsedMilestone[]>
|
||||||
const story: ParsedStory = {
|
const story: ParsedStory = {
|
||||||
ref: taskMatch[2],
|
ref: taskMatch[2],
|
||||||
title: `${taskMatch[2]}: ${taskMatch[3]}`,
|
title: `${taskMatch[2]}: ${taskMatch[3]}`,
|
||||||
description: '',
|
|
||||||
acceptance_criteria: '',
|
acceptance_criteria: '',
|
||||||
status: taskMatch[1] === 'x' ? 'DONE' : 'OPEN',
|
status: taskMatch[1] === 'x' ? 'DONE' : 'OPEN',
|
||||||
sort_order: current.stories.length + 1,
|
sort_order: current.stories.length + 1,
|
||||||
|
tasks: [],
|
||||||
}
|
}
|
||||||
current.stories.push(story)
|
current.stories.push(story)
|
||||||
pending = { story, bodyLines: [] }
|
pending = { story, bullets: [], activeBullet: null }
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -120,22 +169,20 @@ export async function loadBacklog(repoRoot: string): Promise<ParsedMilestone[]>
|
||||||
|
|
||||||
const subMatch = raw.match(SUB_BULLET)
|
const subMatch = raw.match(SUB_BULLET)
|
||||||
if (subMatch) {
|
if (subMatch) {
|
||||||
|
if (pending.activeBullet) {
|
||||||
|
pending.bullets.push(pending.activeBullet)
|
||||||
|
}
|
||||||
const content = subMatch[1]
|
const content = subMatch[1]
|
||||||
if (/^Done when:/i.test(content)) {
|
pending.activeBullet = {
|
||||||
pending.story.acceptance_criteria = content.replace(/^Done when:\s*/i, '').trim()
|
headLine: content.replace(/^Done when:\s*/i, ''),
|
||||||
} else {
|
nestedLines: [],
|
||||||
pending.bodyLines.push(content)
|
isAcceptance: /^Done when:/i.test(content),
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if (NESTED_LINE.test(raw)) {
|
if (NESTED_LINE.test(raw) && pending.activeBullet) {
|
||||||
const tail = raw.trim()
|
pending.activeBullet.nestedLines.push(raw.trim())
|
||||||
if (pending.bodyLines.length > 0) {
|
|
||||||
pending.bodyLines[pending.bodyLines.length - 1] += '\n' + tail
|
|
||||||
} else {
|
|
||||||
pending.bodyLines.push(tail)
|
|
||||||
}
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -172,10 +219,17 @@ if (import.meta.url === `file://${process.argv[1]}`) {
|
||||||
0,
|
0,
|
||||||
)
|
)
|
||||||
const open = total - done
|
const open = total - done
|
||||||
console.log(`Parsed ${milestones.length} milestones, ${total} stories (${done} DONE, ${open} OPEN)`)
|
const totalTasks = milestones.reduce(
|
||||||
|
(acc, m) => acc + m.stories.reduce((a, s) => a + s.tasks.length, 0),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
console.log(
|
||||||
|
`Parsed ${milestones.length} milestones, ${total} stories (${done} DONE, ${open} OPEN), ${totalTasks} tasks`,
|
||||||
|
)
|
||||||
for (const m of milestones) {
|
for (const m of milestones) {
|
||||||
|
const ms = m.stories.reduce((a, s) => a + s.tasks.length, 0)
|
||||||
console.log(
|
console.log(
|
||||||
` ${m.key.padEnd(5)} ${m.title.padEnd(36)} priority=${m.priority} sprint=${m.sprint_status} stories=${m.stories.length}`,
|
` ${m.key.padEnd(5)} ${m.title.padEnd(36)} priority=${m.priority} sprint=${m.sprint_status} stories=${m.stories.length} tasks=${ms}`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -150,6 +150,7 @@ async function main() {
|
||||||
const inSprint = isActive || s.status === 'DONE'
|
const inSprint = isActive || s.status === 'DONE'
|
||||||
const storyStatus =
|
const storyStatus =
|
||||||
s.status === 'DONE' ? 'DONE' : isActive ? 'IN_SPRINT' : 'OPEN'
|
s.status === 'DONE' ? 'DONE' : isActive ? 'IN_SPRINT' : 'OPEN'
|
||||||
|
const storySummary = s.tasks.map((t) => t.title).join('; ')
|
||||||
|
|
||||||
const story = await prisma.story.create({
|
const story = await prisma.story.create({
|
||||||
data: {
|
data: {
|
||||||
|
|
@ -157,7 +158,7 @@ async function main() {
|
||||||
product_id: product.id,
|
product_id: product.id,
|
||||||
sprint_id: inSprint ? sprint.id : null,
|
sprint_id: inSprint ? sprint.id : null,
|
||||||
title: s.title,
|
title: s.title,
|
||||||
description: s.description,
|
description: storySummary,
|
||||||
acceptance_criteria: s.acceptance_criteria,
|
acceptance_criteria: s.acceptance_criteria,
|
||||||
priority: ms.priority,
|
priority: ms.priority,
|
||||||
sort_order: s.sort_order,
|
sort_order: s.sort_order,
|
||||||
|
|
@ -165,17 +166,19 @@ async function main() {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
await prisma.task.create({
|
for (const t of s.tasks) {
|
||||||
data: {
|
await prisma.task.create({
|
||||||
story_id: story.id,
|
data: {
|
||||||
sprint_id: inSprint ? sprint.id : null,
|
story_id: story.id,
|
||||||
title: 'Implementatie',
|
sprint_id: inSprint ? sprint.id : null,
|
||||||
description: s.description,
|
title: t.title,
|
||||||
priority: ms.priority,
|
description: t.description,
|
||||||
sort_order: 1.0,
|
priority: ms.priority,
|
||||||
status: s.status === 'DONE' ? 'DONE' : 'TO_DO',
|
sort_order: t.sort_order,
|
||||||
},
|
status: s.status === 'DONE' ? 'DONE' : 'TO_DO',
|
||||||
})
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue