- Sprint lifecycle: ACTIVE→OPEN, COMPLETED→CLOSED, +ARCHIVED (FAILED behouden) - TaskStatus: +EXCLUDED (overgeslagen door agent-loop via bestaande TO_DO filter) - Cookie-gebaseerde actieve sprint per product (lib/active-sprint.ts) - Route splitsen: /products/[id]/sprint/[sprintId] + /sprint redirect-page - NavBar: gestapelde product/sprint dropdowns + BUILDING-badge derivatie - Backlog selectie-modus + nieuwe-sprint-dialog (createSprintWithPbisAction) - Migratie 20260507210000_sprint_lifecycle: ALTER TYPE RENAME (geen data-rewrite) - Version bump 1.0.0 → 1.2.0 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
261 lines
7.3 KiB
TypeScript
261 lines
7.3 KiB
TypeScript
import { readFile } from 'node:fs/promises'
|
|
import path from 'node:path'
|
|
|
|
export type ParsedTask = {
|
|
title: string
|
|
description: string
|
|
sort_order: number
|
|
}
|
|
|
|
export type ParsedStory = {
|
|
ref: string
|
|
title: string
|
|
acceptance_criteria: string
|
|
status: 'DONE' | 'OPEN'
|
|
sort_order: number
|
|
tasks: ParsedTask[]
|
|
}
|
|
|
|
export type ParsedMilestone = {
|
|
key: string
|
|
title: string
|
|
goal: string
|
|
priority: 1 | 2 | 3 | 4
|
|
sprint_status: 'OPEN' | 'CLOSED'
|
|
sort_order: number
|
|
stories: ParsedStory[]
|
|
}
|
|
|
|
const MILESTONE_HEADER = /^### (M[\d.]+|PBI-\d+):\s*(.+?)\s*$/
|
|
const TASK_BULLET = /^- \[(x| )\] \*\*(ST-\d+)\*\*\s+(.+?)\s*$/
|
|
const SUB_BULLET = /^ {2}- (.+?)\s*$/
|
|
const NESTED_LINE = /^ {4,}\S/
|
|
const SECTION_BREAK = /^---\s*$/
|
|
const BOLD_PREFIX = /^\*\*([^*]+?)\*\*\s*:?\s*(.*)$/
|
|
|
|
const MILESTONE_PRIORITY: Record<string, 1 | 2 | 3 | 4> = {
|
|
M0: 1,
|
|
M1: 1,
|
|
M2: 1,
|
|
M3: 1,
|
|
'M3.5': 2,
|
|
M4: 2,
|
|
M5: 3,
|
|
M6: 4,
|
|
M7: 4,
|
|
M8: 4,
|
|
M9: 4,
|
|
M10: 4,
|
|
M11: 4,
|
|
}
|
|
|
|
const MILESTONE_GOAL: Record<string, string> = {
|
|
M0: 'Project, database, auth, navigatieshell',
|
|
M1: "Producten, PBI's, gesplitst scherm",
|
|
M2: 'Stories als blokken, dnd-kit, Zustand',
|
|
M3: 'Sprint aanmaken, stories slepen, taken',
|
|
'M3.5': 'Story-claim, persoonlijk Kanban-bord per product',
|
|
M4: 'Alle endpoints, tokenbeheer',
|
|
M5: 'Todo CRUD, promotie naar PBI/story; Data Table + detail-kaart',
|
|
M6: 'Foutafhandeling, toegankelijkheid, CI/CD, beveiliging',
|
|
M7: 'MCP-server voor Claude Code',
|
|
M8: 'Realtime updates voor Solo Paneel',
|
|
M9: 'Actief Product Backlog — persistent gekozen product',
|
|
M10: 'Password-loze inlog via QR-pairing',
|
|
M11: 'Vraag-antwoord-kanaal Claude ↔ user',
|
|
}
|
|
|
|
const MILESTONE_SPRINT_STATUS: Record<string, ParsedMilestone['sprint_status']> = {
|
|
M0: 'CLOSED',
|
|
M1: 'CLOSED',
|
|
M2: 'CLOSED',
|
|
M3: 'CLOSED',
|
|
'M3.5': 'CLOSED',
|
|
M4: 'CLOSED',
|
|
M5: 'CLOSED',
|
|
M6: 'CLOSED',
|
|
M7: 'CLOSED',
|
|
M8: 'CLOSED',
|
|
M9: 'CLOSED',
|
|
M10: 'CLOSED',
|
|
M11: 'CLOSED',
|
|
}
|
|
|
|
const MILESTONE_KEY = /^(?:M[\d.]+|PBI-\d+)$/
|
|
|
|
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,
|
|
options: { strict?: boolean } = {},
|
|
): Promise<ParsedMilestone[]> {
|
|
const { strict = true } = options
|
|
const file = path.join(repoRoot, 'docs/scrum4me-backlog.md')
|
|
const md = await readFile(file, 'utf8')
|
|
|
|
const milestones: ParsedMilestone[] = []
|
|
let current: ParsedMilestone | null = null
|
|
let pending: {
|
|
story: ParsedStory
|
|
bullets: SubBullet[]
|
|
activeBullet: SubBullet | null
|
|
} | null = null
|
|
|
|
const flushPending = () => {
|
|
if (!pending) return
|
|
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
|
|
}
|
|
|
|
for (const raw of md.split('\n')) {
|
|
const headerMatch = raw.match(MILESTONE_HEADER)
|
|
if (headerMatch && MILESTONE_KEY.test(headerMatch[1])) {
|
|
flushPending()
|
|
const key = headerMatch[1]
|
|
const title = headerMatch[2]
|
|
current = {
|
|
key,
|
|
title,
|
|
goal: MILESTONE_GOAL[key] ?? title,
|
|
priority: MILESTONE_PRIORITY[key] ?? 4,
|
|
sprint_status: MILESTONE_SPRINT_STATUS[key] ?? 'CLOSED',
|
|
sort_order: milestones.length + 1,
|
|
stories: [],
|
|
}
|
|
milestones.push(current)
|
|
continue
|
|
}
|
|
|
|
if (SECTION_BREAK.test(raw)) {
|
|
flushPending()
|
|
continue
|
|
}
|
|
|
|
if (!current) continue
|
|
|
|
const taskMatch = raw.match(TASK_BULLET)
|
|
if (taskMatch) {
|
|
flushPending()
|
|
const story: ParsedStory = {
|
|
ref: taskMatch[2],
|
|
title: taskMatch[3],
|
|
acceptance_criteria: '',
|
|
status: taskMatch[1] === 'x' ? 'DONE' : 'OPEN',
|
|
sort_order: current.stories.length + 1,
|
|
tasks: [],
|
|
}
|
|
current.stories.push(story)
|
|
pending = { story, bullets: [], activeBullet: null }
|
|
continue
|
|
}
|
|
|
|
if (!pending) continue
|
|
|
|
const subMatch = raw.match(SUB_BULLET)
|
|
if (subMatch) {
|
|
if (pending.activeBullet) {
|
|
pending.bullets.push(pending.activeBullet)
|
|
}
|
|
const content = subMatch[1]
|
|
pending.activeBullet = {
|
|
headLine: content.replace(/^Done when:\s*/i, ''),
|
|
nestedLines: [],
|
|
isAcceptance: /^Done when:/i.test(content),
|
|
}
|
|
continue
|
|
}
|
|
|
|
if (NESTED_LINE.test(raw) && pending.activeBullet) {
|
|
pending.activeBullet.nestedLines.push(raw.trim())
|
|
continue
|
|
}
|
|
|
|
if (raw.trim() === '') continue
|
|
|
|
flushPending()
|
|
}
|
|
|
|
flushPending()
|
|
|
|
if (strict) {
|
|
if (milestones.length < 8) {
|
|
throw new Error(
|
|
`Backlog parser found only ${milestones.length} milestones (expected 8). Format may have drifted in ${file}.`,
|
|
)
|
|
}
|
|
const totalStories = milestones.reduce((acc, m) => acc + m.stories.length, 0)
|
|
if (totalStories < 60) {
|
|
throw new Error(
|
|
`Backlog parser found only ${totalStories} stories (expected ≥ 60). Format may have drifted in ${file}.`,
|
|
)
|
|
}
|
|
}
|
|
|
|
return milestones
|
|
}
|
|
|
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
const repoRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..', '..')
|
|
loadBacklog(repoRoot)
|
|
.then((milestones) => {
|
|
const total = milestones.reduce((acc, m) => acc + m.stories.length, 0)
|
|
const done = milestones.reduce(
|
|
(acc, m) => acc + m.stories.filter((s) => s.status === 'DONE').length,
|
|
0,
|
|
)
|
|
const open = total - done
|
|
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) {
|
|
const ms = m.stories.reduce((a, s) => a + s.tasks.length, 0)
|
|
console.log(
|
|
` ${m.key.padEnd(5)} ${m.title.padEnd(36)} priority=${m.priority} sprint=${m.sprint_status} stories=${m.stories.length} tasks=${ms}`,
|
|
)
|
|
}
|
|
})
|
|
.catch((err) => {
|
|
console.error(err)
|
|
process.exit(1)
|
|
})
|
|
}
|