feat(tooling): add db:insert-milestone for idempotent backlog inserts (#7)

Een herbruikbaar script dat één milestone (PBI + stories + tasks) uit
docs/scrum4me-backlog.md leest en idempotent toevoegt aan de DB,
zonder bestaande data of sprint-toewijzingen te raken.

  npm run db:insert-milestone -- M8                 # standaard SCRUM4ME
  npm run db:insert-milestone -- M8 --product CODE  # ander product
  npm run db:insert-milestone -- M8 --dry-run       # niets schrijven

Parser-uitbreiding (parse-backlog.ts):
- M7 en M8 toegevoegd aan priority/goal/sprint_status maps
- Onbekende milestones krijgen defaults (priority 4, sprint COMPLETED,
  goal = header-titel) zodat M9..M∞ vanzelf werken
- Optionele { strict: false } skipt de "≥ 8 milestones / ≥ 60 stories"
  asserts; de bestaande seed-flow behoudt strict: true (default)

Idempotency:
- Pbi upsert by (product_id, code = milestone.key)
- Story upsert by (product_id, code = ST-XXX)
- Tasks alleen aangemaakt als de story op dit moment 0 tasks heeft

Sprint-toewijzing wordt expliciet niet meegenomen — stories landen in
de backlog en moeten via Sprint Planning in een sprint geplaatst worden.

Geverifieerd: M8 inserted (PBI + 6 stories + 6 tasks), tweede run laat
zien "PBI reused, 0 created" — geen duplicaten.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-04-27 01:25:05 +02:00 committed by GitHub
parent d6c6a3e928
commit f6132960a5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 230 additions and 19 deletions

View file

@ -42,6 +42,8 @@ const MILESTONE_PRIORITY: Record<string, 1 | 2 | 3 | 4> = {
M4: 2,
M5: 3,
M6: 4,
M7: 4,
M8: 4,
}
const MILESTONE_GOAL: Record<string, string> = {
@ -53,6 +55,8 @@ const MILESTONE_GOAL: Record<string, string> = {
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',
}
const MILESTONE_SPRINT_STATUS: Record<string, ParsedMilestone['sprint_status']> = {
@ -64,9 +68,11 @@ const MILESTONE_SPRINT_STATUS: Record<string, ParsedMilestone['sprint_status']>
M4: 'COMPLETED',
M5: 'COMPLETED',
M6: 'COMPLETED',
M7: 'COMPLETED',
M8: 'COMPLETED',
}
const KNOWN_KEYS = Object.keys(MILESTONE_PRIORITY)
const MILESTONE_KEY = /^M[\d.]+$/
type SubBullet = {
headLine: string
@ -96,7 +102,11 @@ function buildAcceptance(b: SubBullet): string {
return [head, nested].filter(Boolean).join('\n').trim()
}
export async function loadBacklog(repoRoot: string): Promise<ParsedMilestone[]> {
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')
@ -126,15 +136,16 @@ export async function loadBacklog(repoRoot: string): Promise<ParsedMilestone[]>
for (const raw of md.split('\n')) {
const headerMatch = raw.match(MILESTONE_HEADER)
if (headerMatch && KNOWN_KEYS.includes(headerMatch[1])) {
if (headerMatch && MILESTONE_KEY.test(headerMatch[1])) {
flushPending()
const key = headerMatch[1]
const title = headerMatch[2]
current = {
key,
title: headerMatch[2],
goal: MILESTONE_GOAL[key],
priority: MILESTONE_PRIORITY[key],
sprint_status: MILESTONE_SPRINT_STATUS[key],
title,
goal: MILESTONE_GOAL[key] ?? title,
priority: MILESTONE_PRIORITY[key] ?? 4,
sprint_status: MILESTONE_SPRINT_STATUS[key] ?? 'COMPLETED',
sort_order: milestones.length + 1,
stories: [],
}
@ -193,17 +204,18 @@ export async function loadBacklog(repoRoot: string): Promise<ParsedMilestone[]>
flushPending()
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}.`,
)
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