diff --git a/package.json b/package.json index a889ecd..b9a7923 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/prisma/seed-data/parse-backlog.ts b/prisma/seed-data/parse-backlog.ts index 7694b1a..8e6cfa1 100644 --- a/prisma/seed-data/parse-backlog.ts +++ b/prisma/seed-data/parse-backlog.ts @@ -42,6 +42,8 @@ const MILESTONE_PRIORITY: Record = { M4: 2, M5: 3, M6: 4, + M7: 4, + M8: 4, } const MILESTONE_GOAL: Record = { @@ -53,6 +55,8 @@ const MILESTONE_GOAL: Record = { 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 = { @@ -64,9 +68,11 @@ const MILESTONE_SPRINT_STATUS: Record 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 { +export async function loadBacklog( + repoRoot: string, + options: { strict?: boolean } = {}, +): Promise { + 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 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 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 diff --git a/scripts/insert-milestone.ts b/scripts/insert-milestone.ts new file mode 100644 index 0000000..97a0457 --- /dev/null +++ b/scripts/insert-milestone.ts @@ -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 [--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 = '' + 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 = `` + 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) +})