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 <noreply@anthropic.com>
This commit is contained in:
parent
85111f6dc7
commit
49defa9686
5 changed files with 198 additions and 71 deletions
|
|
@ -140,6 +140,7 @@ model Product {
|
||||||
pbis Pbi[]
|
pbis Pbi[]
|
||||||
sprints Sprint[]
|
sprints Sprint[]
|
||||||
stories Story[]
|
stories Story[]
|
||||||
|
tasks Task[]
|
||||||
todos Todo[]
|
todos Todo[]
|
||||||
members ProductMember[]
|
members ProductMember[]
|
||||||
active_for_users User[] @relation("UserActiveProduct")
|
active_for_users User[] @relation("UserActiveProduct")
|
||||||
|
|
@ -156,7 +157,7 @@ model Pbi {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
product Product @relation(fields: [product_id], references: [id], onDelete: Cascade)
|
product Product @relation(fields: [product_id], references: [id], onDelete: Cascade)
|
||||||
product_id String
|
product_id String
|
||||||
code String? @db.VarChar(30)
|
code String @db.VarChar(30)
|
||||||
title String
|
title String
|
||||||
description String?
|
description String?
|
||||||
priority Int
|
priority Int
|
||||||
|
|
@ -165,8 +166,8 @@ model Pbi {
|
||||||
pr_url String?
|
pr_url String?
|
||||||
pr_merged_at DateTime?
|
pr_merged_at DateTime?
|
||||||
created_at DateTime @default(now())
|
created_at DateTime @default(now())
|
||||||
updated_at DateTime @updatedAt
|
updated_at DateTime @updatedAt
|
||||||
stories Story[]
|
stories Story[]
|
||||||
|
|
||||||
@@unique([product_id, code])
|
@@unique([product_id, code])
|
||||||
@@index([product_id, priority, sort_order])
|
@@index([product_id, priority, sort_order])
|
||||||
|
|
@ -184,7 +185,7 @@ model Story {
|
||||||
sprint_id String?
|
sprint_id String?
|
||||||
assignee User? @relation("StoryAssignee", fields: [assignee_id], references: [id], onDelete: SetNull)
|
assignee User? @relation("StoryAssignee", fields: [assignee_id], references: [id], onDelete: SetNull)
|
||||||
assignee_id String?
|
assignee_id String?
|
||||||
code String? @db.VarChar(30)
|
code String @db.VarChar(30)
|
||||||
title String
|
title String
|
||||||
description String?
|
description String?
|
||||||
acceptance_criteria String?
|
acceptance_criteria String?
|
||||||
|
|
@ -242,8 +243,11 @@ model Task {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
story Story @relation(fields: [story_id], references: [id], onDelete: Cascade)
|
story Story @relation(fields: [story_id], references: [id], onDelete: Cascade)
|
||||||
story_id String
|
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 Sprint? @relation(fields: [sprint_id], references: [id])
|
||||||
sprint_id String?
|
sprint_id String?
|
||||||
|
code String @db.VarChar(30)
|
||||||
title String
|
title String
|
||||||
description String?
|
description String?
|
||||||
implementation_plan String?
|
implementation_plan String?
|
||||||
|
|
@ -262,8 +266,10 @@ model Task {
|
||||||
claude_questions ClaudeQuestion[]
|
claude_questions ClaudeQuestion[]
|
||||||
claude_jobs ClaudeJob[]
|
claude_jobs ClaudeJob[]
|
||||||
|
|
||||||
|
@@unique([product_id, code])
|
||||||
@@index([story_id, priority, sort_order])
|
@@index([story_id, priority, sort_order])
|
||||||
@@index([sprint_id, status])
|
@@index([sprint_id, status])
|
||||||
|
@@index([product_id])
|
||||||
@@map("tasks")
|
@@map("tasks")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,44 @@
|
||||||
// MCP authoring tool: create een Product Backlog Item.
|
// MCP authoring tool: create een Product Backlog Item.
|
||||||
//
|
//
|
||||||
// Sort_order wordt automatisch op last+1 binnen de prioriteits-groep gezet als
|
// 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
|
// niet meegegeven. Code wordt auto-gegenereerd als PBI-N (zelfde logica als de
|
||||||
// door de Scrum4Me-app gegenereerd, kan optioneel later via UI worden gezet.
|
// Scrum4Me-app), met retry bij een race-condition op de unique constraint.
|
||||||
|
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||||
|
import { Prisma } from '@prisma/client'
|
||||||
import { prisma } from '../prisma.js'
|
import { prisma } from '../prisma.js'
|
||||||
import { requireWriteAccess } from '../auth.js'
|
import { requireWriteAccess } from '../auth.js'
|
||||||
import { userCanAccessProduct } from '../access.js'
|
import { userCanAccessProduct } from '../access.js'
|
||||||
import { toolError, toolJson, withToolErrors } from '../errors.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<string> {
|
||||||
|
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({
|
const inputSchema = z.object({
|
||||||
product_id: z.string().min(1),
|
product_id: z.string().min(1),
|
||||||
title: z.string().min(1).max(200),
|
title: z.string().min(1).max(200),
|
||||||
|
|
@ -45,24 +73,36 @@ export function registerCreatePbiTool(server: McpServer) {
|
||||||
resolvedSortOrder = (last?.sort_order ?? 0) + 1.0
|
resolvedSortOrder = (last?.sort_order ?? 0) + 1.0
|
||||||
}
|
}
|
||||||
|
|
||||||
const pbi = await prisma.pbi.create({
|
let lastError: unknown
|
||||||
data: {
|
for (let attempt = 0; attempt < MAX_CODE_ATTEMPTS; attempt++) {
|
||||||
product_id,
|
const code = await generateNextPbiCode(product_id)
|
||||||
title,
|
try {
|
||||||
description: description ?? null,
|
const pbi = await prisma.pbi.create({
|
||||||
priority,
|
data: {
|
||||||
sort_order: resolvedSortOrder,
|
product_id,
|
||||||
},
|
code,
|
||||||
select: {
|
title,
|
||||||
id: true,
|
description: description ?? null,
|
||||||
title: true,
|
priority,
|
||||||
description: true,
|
sort_order: resolvedSortOrder,
|
||||||
priority: true,
|
},
|
||||||
sort_order: true,
|
select: {
|
||||||
created_at: true,
|
id: true,
|
||||||
},
|
code: true,
|
||||||
})
|
title: true,
|
||||||
return toolJson(pbi)
|
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')
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,39 @@
|
||||||
|
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||||
|
import { Prisma } from '@prisma/client'
|
||||||
import { prisma } from '../prisma.js'
|
import { prisma } from '../prisma.js'
|
||||||
import { requireWriteAccess } from '../auth.js'
|
import { requireWriteAccess } from '../auth.js'
|
||||||
import { userCanAccessProduct } from '../access.js'
|
import { userCanAccessProduct } from '../access.js'
|
||||||
import { toolError, toolJson, withToolErrors } from '../errors.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<string> {
|
||||||
|
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({
|
const inputSchema = z.object({
|
||||||
pbi_id: z.string().min(1),
|
pbi_id: z.string().min(1),
|
||||||
title: z.string().min(1).max(200),
|
title: z.string().min(1).max(200),
|
||||||
|
|
@ -52,29 +80,41 @@ export function registerCreateStoryTool(server: McpServer) {
|
||||||
resolvedSortOrder = (last?.sort_order ?? 0) + 1.0
|
resolvedSortOrder = (last?.sort_order ?? 0) + 1.0
|
||||||
}
|
}
|
||||||
|
|
||||||
const story = await prisma.story.create({
|
let lastError: unknown
|
||||||
data: {
|
for (let attempt = 0; attempt < MAX_CODE_ATTEMPTS; attempt++) {
|
||||||
pbi_id,
|
const code = await generateNextStoryCode(pbi.product_id)
|
||||||
product_id: pbi.product_id, // denormalized uit DB-parent, niet uit input
|
try {
|
||||||
title,
|
const story = await prisma.story.create({
|
||||||
description: description ?? null,
|
data: {
|
||||||
acceptance_criteria: acceptance_criteria ?? null,
|
pbi_id,
|
||||||
priority,
|
product_id: pbi.product_id, // denormalized uit DB-parent, niet uit input
|
||||||
sort_order: resolvedSortOrder,
|
code,
|
||||||
status: 'OPEN',
|
title,
|
||||||
},
|
description: description ?? null,
|
||||||
select: {
|
acceptance_criteria: acceptance_criteria ?? null,
|
||||||
id: true,
|
priority,
|
||||||
title: true,
|
sort_order: resolvedSortOrder,
|
||||||
description: true,
|
status: 'OPEN',
|
||||||
acceptance_criteria: true,
|
},
|
||||||
priority: true,
|
select: {
|
||||||
sort_order: true,
|
id: true,
|
||||||
status: true,
|
code: true,
|
||||||
created_at: true,
|
title: true,
|
||||||
},
|
description: true,
|
||||||
})
|
acceptance_criteria: true,
|
||||||
return toolJson(story)
|
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')
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,39 @@
|
||||||
|
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||||
|
import { Prisma } from '@prisma/client'
|
||||||
import { prisma } from '../prisma.js'
|
import { prisma } from '../prisma.js'
|
||||||
import { requireWriteAccess } from '../auth.js'
|
import { requireWriteAccess } from '../auth.js'
|
||||||
import { userCanAccessProduct } from '../access.js'
|
import { userCanAccessProduct } from '../access.js'
|
||||||
import { toolError, toolJson, withToolErrors } from '../errors.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<string> {
|
||||||
|
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({
|
const inputSchema = z.object({
|
||||||
story_id: z.string().min(1),
|
story_id: z.string().min(1),
|
||||||
title: z.string().min(1).max(200),
|
title: z.string().min(1).max(200),
|
||||||
|
|
@ -51,29 +79,42 @@ export function registerCreateTaskTool(server: McpServer) {
|
||||||
resolvedSortOrder = (last?.sort_order ?? 0) + 1.0
|
resolvedSortOrder = (last?.sort_order ?? 0) + 1.0
|
||||||
}
|
}
|
||||||
|
|
||||||
const task = await prisma.task.create({
|
let lastError: unknown
|
||||||
data: {
|
for (let attempt = 0; attempt < MAX_CODE_ATTEMPTS; attempt++) {
|
||||||
story_id,
|
const code = await generateNextTaskCode(story.product_id)
|
||||||
sprint_id: story.sprint_id, // denormalized — erf van story
|
try {
|
||||||
title,
|
const task = await prisma.task.create({
|
||||||
description: description ?? null,
|
data: {
|
||||||
implementation_plan: implementation_plan ?? null,
|
story_id,
|
||||||
priority,
|
product_id: story.product_id, // denormalized — erf van story
|
||||||
sort_order: resolvedSortOrder,
|
sprint_id: story.sprint_id, // denormalized — erf van story
|
||||||
status: 'TO_DO',
|
code,
|
||||||
},
|
title,
|
||||||
select: {
|
description: description ?? null,
|
||||||
id: true,
|
implementation_plan: implementation_plan ?? null,
|
||||||
title: true,
|
priority,
|
||||||
description: true,
|
sort_order: resolvedSortOrder,
|
||||||
implementation_plan: true,
|
status: 'TO_DO',
|
||||||
priority: true,
|
},
|
||||||
sort_order: true,
|
select: {
|
||||||
status: true,
|
id: true,
|
||||||
created_at: true,
|
code: true,
|
||||||
},
|
title: true,
|
||||||
})
|
description: true,
|
||||||
return toolJson(task)
|
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')
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
2
vendor/scrum4me
vendored
2
vendor/scrum4me
vendored
|
|
@ -1 +1 @@
|
||||||
Subproject commit a754acf13ba68c411d73060537ef356037230065
|
Subproject commit 90343573f399544e386b2833d23a74f0fa122fa6
|
||||||
Loading…
Add table
Add a link
Reference in a new issue