From 49defa96867c9d2ed2fbb569d01dd5ff4e67930f Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Mon, 4 May 2026 16:14:36 +0200 Subject: [PATCH] feat: auto-generate codes for PBI/Story/Task on create Code field became required in schema (feat/entity-codes-required). All three create tools now generate PBI-N / ST-001 / T-N via the same SELECT-MAX + retry pattern used in the Scrum4Me app. Also bumps vendor submodule to v1.0.0 and regenerates prisma/schema.prisma. Co-Authored-By: Claude Sonnet 4.6 --- prisma/schema.prisma | 14 +++++-- src/tools/create-pbi.ts | 80 ++++++++++++++++++++++++++--------- src/tools/create-story.ts | 86 +++++++++++++++++++++++++++----------- src/tools/create-task.ts | 87 ++++++++++++++++++++++++++++----------- vendor/scrum4me | 2 +- 5 files changed, 198 insertions(+), 71 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2dde3c4..5a0ab4f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -140,6 +140,7 @@ model Product { pbis Pbi[] sprints Sprint[] stories Story[] + tasks Task[] todos Todo[] members ProductMember[] active_for_users User[] @relation("UserActiveProduct") @@ -156,7 +157,7 @@ model Pbi { id String @id @default(cuid()) product Product @relation(fields: [product_id], references: [id], onDelete: Cascade) product_id String - code String? @db.VarChar(30) + code String @db.VarChar(30) title String description String? priority Int @@ -165,8 +166,8 @@ model Pbi { pr_url String? pr_merged_at DateTime? created_at DateTime @default(now()) - updated_at DateTime @updatedAt - stories Story[] + updated_at DateTime @updatedAt + stories Story[] @@unique([product_id, code]) @@index([product_id, priority, sort_order]) @@ -184,7 +185,7 @@ model Story { sprint_id String? assignee User? @relation("StoryAssignee", fields: [assignee_id], references: [id], onDelete: SetNull) assignee_id String? - code String? @db.VarChar(30) + code String @db.VarChar(30) title String description String? acceptance_criteria String? @@ -242,8 +243,11 @@ model Task { id String @id @default(cuid()) story Story @relation(fields: [story_id], references: [id], onDelete: Cascade) story_id String + product Product @relation(fields: [product_id], references: [id], onDelete: Cascade) + product_id String sprint Sprint? @relation(fields: [sprint_id], references: [id]) sprint_id String? + code String @db.VarChar(30) title String description String? implementation_plan String? @@ -262,8 +266,10 @@ model Task { claude_questions ClaudeQuestion[] claude_jobs ClaudeJob[] + @@unique([product_id, code]) @@index([story_id, priority, sort_order]) @@index([sprint_id, status]) + @@index([product_id]) @@map("tasks") } diff --git a/src/tools/create-pbi.ts b/src/tools/create-pbi.ts index 7090114..780598c 100644 --- a/src/tools/create-pbi.ts +++ b/src/tools/create-pbi.ts @@ -1,16 +1,44 @@ // MCP authoring tool: create een Product Backlog Item. // // Sort_order wordt automatisch op last+1 binnen de prioriteits-groep gezet als -// niet meegegeven. Code-veld blijft null — auto-codes (PBI-1, PBI-2, …) worden -// door de Scrum4Me-app gegenereerd, kan optioneel later via UI worden gezet. +// niet meegegeven. Code wordt auto-gegenereerd als PBI-N (zelfde logica als de +// Scrum4Me-app), met retry bij een race-condition op de unique constraint. import { z } from 'zod' import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { Prisma } from '@prisma/client' import { prisma } from '../prisma.js' import { requireWriteAccess } from '../auth.js' import { userCanAccessProduct } from '../access.js' import { toolError, toolJson, withToolErrors } from '../errors.js' +const PBI_AUTO_RE = /^PBI-(\d+)$/ +const MAX_CODE_ATTEMPTS = 3 + +async function generateNextPbiCode(productId: string): Promise { + const pbis = await prisma.pbi.findMany({ + where: { product_id: productId }, + select: { code: true }, + }) + let max = 0 + for (const p of pbis) { + const m = p.code?.match(PBI_AUTO_RE) + if (m) { + const n = Number.parseInt(m[1], 10) + if (!Number.isNaN(n) && n > max) max = n + } + } + return `PBI-${max + 1}` +} + +function isCodeUniqueConflict(error: unknown): boolean { + if (!(error instanceof Prisma.PrismaClientKnownRequestError)) return false + if (error.code !== 'P2002') return false + const target = (error.meta as { target?: string[] | string } | undefined)?.target + if (!target) return false + return Array.isArray(target) ? target.includes('code') : target.includes('code') +} + const inputSchema = z.object({ product_id: z.string().min(1), title: z.string().min(1).max(200), @@ -45,24 +73,36 @@ export function registerCreatePbiTool(server: McpServer) { resolvedSortOrder = (last?.sort_order ?? 0) + 1.0 } - const pbi = await prisma.pbi.create({ - data: { - product_id, - title, - description: description ?? null, - priority, - sort_order: resolvedSortOrder, - }, - select: { - id: true, - title: true, - description: true, - priority: true, - sort_order: true, - created_at: true, - }, - }) - return toolJson(pbi) + let lastError: unknown + for (let attempt = 0; attempt < MAX_CODE_ATTEMPTS; attempt++) { + const code = await generateNextPbiCode(product_id) + try { + const pbi = await prisma.pbi.create({ + data: { + product_id, + code, + title, + description: description ?? null, + priority, + sort_order: resolvedSortOrder, + }, + select: { + id: true, + code: true, + title: true, + description: true, + priority: true, + sort_order: true, + created_at: true, + }, + }) + return toolJson(pbi) + } catch (e) { + if (isCodeUniqueConflict(e)) { lastError = e; continue } + throw e + } + } + throw lastError ?? new Error('Kon geen unieke PBI-code genereren') }), ) } diff --git a/src/tools/create-story.ts b/src/tools/create-story.ts index 5f9877a..cfa099e 100644 --- a/src/tools/create-story.ts +++ b/src/tools/create-story.ts @@ -6,11 +6,39 @@ import { z } from 'zod' import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { Prisma } from '@prisma/client' import { prisma } from '../prisma.js' import { requireWriteAccess } from '../auth.js' import { userCanAccessProduct } from '../access.js' import { toolError, toolJson, withToolErrors } from '../errors.js' +const STORY_AUTO_RE = /^ST-(\d+)$/ +const MAX_CODE_ATTEMPTS = 3 + +async function generateNextStoryCode(productId: string): Promise { + const stories = await prisma.story.findMany({ + where: { product_id: productId }, + select: { code: true }, + }) + let max = 0 + for (const s of stories) { + const m = s.code?.match(STORY_AUTO_RE) + if (m) { + const n = Number.parseInt(m[1], 10) + if (!Number.isNaN(n) && n > max) max = n + } + } + return `ST-${String(max + 1).padStart(3, '0')}` +} + +function isCodeUniqueConflict(error: unknown): boolean { + if (!(error instanceof Prisma.PrismaClientKnownRequestError)) return false + if (error.code !== 'P2002') return false + const target = (error.meta as { target?: string[] | string } | undefined)?.target + if (!target) return false + return Array.isArray(target) ? target.includes('code') : target.includes('code') +} + const inputSchema = z.object({ pbi_id: z.string().min(1), title: z.string().min(1).max(200), @@ -52,29 +80,41 @@ export function registerCreateStoryTool(server: McpServer) { resolvedSortOrder = (last?.sort_order ?? 0) + 1.0 } - const story = await prisma.story.create({ - data: { - pbi_id, - product_id: pbi.product_id, // denormalized uit DB-parent, niet uit input - title, - description: description ?? null, - acceptance_criteria: acceptance_criteria ?? null, - priority, - sort_order: resolvedSortOrder, - status: 'OPEN', - }, - select: { - id: true, - title: true, - description: true, - acceptance_criteria: true, - priority: true, - sort_order: true, - status: true, - created_at: true, - }, - }) - return toolJson(story) + let lastError: unknown + for (let attempt = 0; attempt < MAX_CODE_ATTEMPTS; attempt++) { + const code = await generateNextStoryCode(pbi.product_id) + try { + const story = await prisma.story.create({ + data: { + pbi_id, + product_id: pbi.product_id, // denormalized uit DB-parent, niet uit input + code, + title, + description: description ?? null, + acceptance_criteria: acceptance_criteria ?? null, + priority, + sort_order: resolvedSortOrder, + status: 'OPEN', + }, + select: { + id: true, + code: true, + title: true, + description: true, + acceptance_criteria: true, + priority: true, + sort_order: true, + status: true, + created_at: true, + }, + }) + return toolJson(story) + } catch (e) { + if (isCodeUniqueConflict(e)) { lastError = e; continue } + throw e + } + } + throw lastError ?? new Error('Kon geen unieke Story-code genereren') }), ) } diff --git a/src/tools/create-task.ts b/src/tools/create-task.ts index 70fdd91..91cd7d2 100644 --- a/src/tools/create-task.ts +++ b/src/tools/create-task.ts @@ -5,11 +5,39 @@ import { z } from 'zod' import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { Prisma } from '@prisma/client' import { prisma } from '../prisma.js' import { requireWriteAccess } from '../auth.js' import { userCanAccessProduct } from '../access.js' import { toolError, toolJson, withToolErrors } from '../errors.js' +const TASK_AUTO_RE = /^T-(\d+)$/ +const MAX_CODE_ATTEMPTS = 3 + +async function generateNextTaskCode(productId: string): Promise { + const tasks = await prisma.task.findMany({ + where: { product_id: productId }, + select: { code: true }, + }) + let max = 0 + for (const t of tasks) { + const m = t.code?.match(TASK_AUTO_RE) + if (m) { + const n = Number.parseInt(m[1], 10) + if (!Number.isNaN(n) && n > max) max = n + } + } + return `T-${max + 1}` +} + +function isCodeUniqueConflict(error: unknown): boolean { + if (!(error instanceof Prisma.PrismaClientKnownRequestError)) return false + if (error.code !== 'P2002') return false + const target = (error.meta as { target?: string[] | string } | undefined)?.target + if (!target) return false + return Array.isArray(target) ? target.includes('code') : target.includes('code') +} + const inputSchema = z.object({ story_id: z.string().min(1), title: z.string().min(1).max(200), @@ -51,29 +79,42 @@ export function registerCreateTaskTool(server: McpServer) { resolvedSortOrder = (last?.sort_order ?? 0) + 1.0 } - const task = await prisma.task.create({ - data: { - story_id, - sprint_id: story.sprint_id, // denormalized — erf van story - title, - description: description ?? null, - implementation_plan: implementation_plan ?? null, - priority, - sort_order: resolvedSortOrder, - status: 'TO_DO', - }, - select: { - id: true, - title: true, - description: true, - implementation_plan: true, - priority: true, - sort_order: true, - status: true, - created_at: true, - }, - }) - return toolJson(task) + let lastError: unknown + for (let attempt = 0; attempt < MAX_CODE_ATTEMPTS; attempt++) { + const code = await generateNextTaskCode(story.product_id) + try { + const task = await prisma.task.create({ + data: { + story_id, + product_id: story.product_id, // denormalized — erf van story + sprint_id: story.sprint_id, // denormalized — erf van story + code, + title, + description: description ?? null, + implementation_plan: implementation_plan ?? null, + priority, + sort_order: resolvedSortOrder, + status: 'TO_DO', + }, + select: { + id: true, + code: true, + title: true, + description: true, + implementation_plan: true, + priority: true, + sort_order: true, + status: true, + created_at: true, + }, + }) + return toolJson(task) + } catch (e) { + if (isCodeUniqueConflict(e)) { lastError = e; continue } + throw e + } + } + throw lastError ?? new Error('Kon geen unieke Task-code genereren') }), ) } diff --git a/vendor/scrum4me b/vendor/scrum4me index a754acf..9034357 160000 --- a/vendor/scrum4me +++ b/vendor/scrum4me @@ -1 +1 @@ -Subproject commit a754acf13ba68c411d73060537ef356037230065 +Subproject commit 90343573f399544e386b2833d23a74f0fa122fa6