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:
parent
d6c6a3e928
commit
f6132960a5
3 changed files with 230 additions and 19 deletions
|
|
@ -12,7 +12,8 @@
|
|||
"prepare": "husky",
|
||||
"postinstall": "prisma generate --generator client",
|
||||
"db:erd": "prisma generate",
|
||||
"db:erd:watch": "chokidar \"prisma/schema.prisma\" -c \"npm run db:erd\""
|
||||
"db:erd:watch": "chokidar \"prisma/schema.prisma\" -c \"npm run db:erd\"",
|
||||
"db:insert-milestone": "tsx scripts/insert-milestone.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.4.1",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
198
scripts/insert-milestone.ts
Normal file
198
scripts/insert-milestone.ts
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
// Idempotent insert van één milestone (PBI + stories + tasks) uit de backlog.
|
||||
//
|
||||
// Gebruik:
|
||||
// npm run db:insert-milestone -- M8 # standaard product SCRUM4ME
|
||||
// npm run db:insert-milestone -- M8 --product SCRUM4ME # idem
|
||||
// npm run db:insert-milestone -- M8 --dry-run # niets schrijven, alleen tonen
|
||||
//
|
||||
// Bron: docs/scrum4me-backlog.md (gecommit, single source of truth).
|
||||
// Tweede keer draaien is veilig: bestaande PBIs/stories/tasks worden niet
|
||||
// gedupliceerd. Sprint-toewijzing doe je apart via Sprint Planning in de UI.
|
||||
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
import * as dotenv from 'dotenv'
|
||||
import * as path from 'path'
|
||||
import { Pool } from 'pg'
|
||||
import { PrismaPg } from '@prisma/adapter-pg'
|
||||
import { loadBacklog } from '../prisma/seed-data/parse-backlog'
|
||||
|
||||
const root = path.resolve(__dirname, '..')
|
||||
dotenv.config({ path: path.join(root, '.env.local'), override: true })
|
||||
dotenv.config({ path: path.join(root, '.env') })
|
||||
|
||||
interface Args {
|
||||
milestoneKey: string
|
||||
productCode: string
|
||||
dryRun: boolean
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): Args {
|
||||
const positional: string[] = []
|
||||
let productCode = 'SCRUM4ME'
|
||||
let dryRun = false
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i]
|
||||
if (a === '--product') productCode = argv[++i] ?? ''
|
||||
else if (a === '--dry-run') dryRun = true
|
||||
else if (a.startsWith('--')) throw new Error(`Unknown flag: ${a}`)
|
||||
else positional.push(a)
|
||||
}
|
||||
|
||||
if (positional.length !== 1) {
|
||||
throw new Error('Usage: insert-milestone <milestone-key> [--product CODE] [--dry-run]')
|
||||
}
|
||||
const milestoneKey = positional[0]
|
||||
if (!/^M[\d.]+$/.test(milestoneKey)) {
|
||||
throw new Error(`Invalid milestone key: ${milestoneKey} (expected M0, M3.5, M8, ...)`)
|
||||
}
|
||||
if (!productCode) throw new Error('--product cannot be empty')
|
||||
|
||||
return { milestoneKey, productCode, dryRun }
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2))
|
||||
const url = process.env.DATABASE_URL
|
||||
if (!url) throw new Error('DATABASE_URL is not set. Check .env.local')
|
||||
|
||||
const pool = new Pool({ connectionString: url })
|
||||
const adapter = new PrismaPg(pool)
|
||||
const prisma = new PrismaClient({ adapter })
|
||||
|
||||
try {
|
||||
const milestones = await loadBacklog(root, { strict: false })
|
||||
const ms = milestones.find((m) => m.key === args.milestoneKey)
|
||||
if (!ms) {
|
||||
throw new Error(
|
||||
`Milestone ${args.milestoneKey} not found in docs/scrum4me-backlog.md (parsed: ${milestones.map((m) => m.key).join(', ')})`,
|
||||
)
|
||||
}
|
||||
|
||||
const product = await prisma.product.findFirst({
|
||||
where: { code: args.productCode, archived: false },
|
||||
select: { id: true, name: true, code: true },
|
||||
})
|
||||
if (!product) throw new Error(`Product with code "${args.productCode}" not found`)
|
||||
|
||||
console.log(
|
||||
`Inserting ${ms.key} (${ms.title}) into product ${product.code} — ${ms.stories.length} stories${args.dryRun ? ' [DRY RUN]' : ''}`,
|
||||
)
|
||||
|
||||
let pbiCreated = 0
|
||||
let storiesCreated = 0
|
||||
let storiesUpdated = 0
|
||||
let tasksCreated = 0
|
||||
|
||||
// PBI upsert
|
||||
const existingPbi = await prisma.pbi.findFirst({
|
||||
where: { product_id: product.id, code: ms.key },
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
let pbiId: string
|
||||
if (existingPbi) {
|
||||
pbiId = existingPbi.id
|
||||
} else if (args.dryRun) {
|
||||
pbiId = '<new-pbi-id>'
|
||||
pbiCreated = 1
|
||||
} else {
|
||||
const maxPbi = await prisma.pbi.aggregate({
|
||||
where: { product_id: product.id },
|
||||
_max: { sort_order: true },
|
||||
})
|
||||
const nextSort = (maxPbi._max.sort_order ?? 0) + 1
|
||||
const created = await prisma.pbi.create({
|
||||
data: {
|
||||
product_id: product.id,
|
||||
code: ms.key,
|
||||
title: ms.title,
|
||||
description: ms.goal,
|
||||
priority: ms.priority,
|
||||
sort_order: nextSort,
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
pbiId = created.id
|
||||
pbiCreated = 1
|
||||
}
|
||||
|
||||
// Stories + tasks
|
||||
for (const s of ms.stories) {
|
||||
const existingStory = await prisma.story.findFirst({
|
||||
where: { product_id: product.id, code: s.ref },
|
||||
select: { id: true, _count: { select: { tasks: true } } },
|
||||
})
|
||||
|
||||
const storyData = {
|
||||
pbi_id: pbiId,
|
||||
product_id: product.id,
|
||||
code: s.ref,
|
||||
title: s.title,
|
||||
description: s.tasks.map((t) => t.title).join('; '),
|
||||
acceptance_criteria: s.acceptance_criteria,
|
||||
priority: ms.priority,
|
||||
sort_order: s.sort_order,
|
||||
status: s.status === 'DONE' ? 'DONE' as const : 'OPEN' as const,
|
||||
}
|
||||
|
||||
let storyId: string
|
||||
let hadTasks = false
|
||||
if (existingStory) {
|
||||
hadTasks = existingStory._count.tasks > 0
|
||||
storyId = existingStory.id
|
||||
if (!args.dryRun) {
|
||||
await prisma.story.update({
|
||||
where: { id: existingStory.id },
|
||||
data: storyData,
|
||||
})
|
||||
}
|
||||
storiesUpdated++
|
||||
} else if (args.dryRun) {
|
||||
storyId = `<new-story-${s.ref}>`
|
||||
storiesCreated++
|
||||
} else {
|
||||
const created = await prisma.story.create({
|
||||
data: storyData,
|
||||
select: { id: true },
|
||||
})
|
||||
storyId = created.id
|
||||
storiesCreated++
|
||||
}
|
||||
|
||||
// Tasks: alleen als de story op dit moment 0 tasks had
|
||||
if (!hadTasks && s.tasks.length > 0) {
|
||||
if (!args.dryRun) {
|
||||
await prisma.task.createMany({
|
||||
data: s.tasks.map((t) => ({
|
||||
story_id: storyId,
|
||||
title: t.title,
|
||||
description: t.description || null,
|
||||
priority: ms.priority,
|
||||
sort_order: t.sort_order,
|
||||
status: s.status === 'DONE' ? 'DONE' as const : 'TO_DO' as const,
|
||||
})),
|
||||
})
|
||||
}
|
||||
tasksCreated += s.tasks.length
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Result: PBI ${pbiCreated ? 'created' : 'reused'}, stories ${storiesCreated} created / ${storiesUpdated} updated, tasks ${tasksCreated} created${args.dryRun ? ' [DRY RUN — niets geschreven]' : ''}`,
|
||||
)
|
||||
if (storiesCreated > 0 || pbiCreated > 0) {
|
||||
console.log(
|
||||
`Tip: stories staan nog niet in een sprint. Plan ze via Sprint Planning in de UI als je ze in de Solo Paneel wil zien.`,
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
await prisma.$disconnect()
|
||||
await pool.end()
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('insert-milestone failed:', err.message)
|
||||
process.exit(1)
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue