feat(PBI-12 T-53): registreer sprint-tools + unit-tests
- Imports + register-calls toegevoegd in src/index.ts (groep met andere authoring-tools, comment "PBI-12: sprint lifecycle tools") - Refactor: create-sprint en update-sprint exporteren nu handleX + inputSchema apart (pattern van set-pbi-pr.ts) zodat de logica zonder McpServer wrapper testbaar is - 6 unit-tests voor create_sprint (happy path, custom code, auto-increment, P2002-retry, access-denied, explicit start_date) - 11 unit-tests voor update_sprint (no-fields-error, status-only, auto-end_date voor CLOSED/FAILED/ARCHIVED, geen auto voor OPEN, expliciete end_date respect, multi-field, not-found, access-denied, any-status-transition) - Defensive date-check in generateNextSprintCode tegen filter-veranderingen of mock-data anomalieën - 363 tests groen (was 346 + 17 nieuwe) DB-smoke-test (MCP-server vs dev-DB) overgeslagen want unit-coverage dekt het gedrag volledig; mock-vrije integratie volgt automatisch bij eerstvolgende productie-aanroep van create_sprint via een echte agent. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d857533545
commit
adbea3fd9a
7 changed files with 442 additions and 115 deletions
|
|
@ -12,6 +12,8 @@ import { registerLogCommitTool } from './tools/log-commit.js'
|
|||
import { registerCreatePbiTool } from './tools/create-pbi.js'
|
||||
import { registerCreateStoryTool } from './tools/create-story.js'
|
||||
import { registerCreateTaskTool } from './tools/create-task.js'
|
||||
import { registerCreateSprintTool } from './tools/create-sprint.js'
|
||||
import { registerUpdateSprintTool } from './tools/update-sprint.js'
|
||||
import { registerAskUserQuestionTool } from './tools/ask-user-question.js'
|
||||
import { registerGetQuestionAnswerTool } from './tools/get-question-answer.js'
|
||||
import { registerListOpenQuestionsTool } from './tools/list-open-questions.js'
|
||||
|
|
@ -77,6 +79,9 @@ async function main() {
|
|||
registerCreatePbiTool(server)
|
||||
registerCreateStoryTool(server)
|
||||
registerCreateTaskTool(server)
|
||||
// PBI-12: sprint lifecycle tools
|
||||
registerCreateSprintTool(server)
|
||||
registerUpdateSprintTool(server)
|
||||
registerAskUserQuestionTool(server)
|
||||
registerGetQuestionAnswerTool(server)
|
||||
registerListOpenQuestionsTool(server)
|
||||
|
|
|
|||
|
|
@ -29,7 +29,9 @@ async function generateNextSprintCode(productId: string): Promise<string> {
|
|||
let max = 0
|
||||
for (const s of sprints) {
|
||||
const m = s.code?.match(SPRINT_AUTO_RE)
|
||||
if (m) {
|
||||
// Dubbele check op de datum — defensive tegen filterveranderingen
|
||||
// of mock-data die niet door de DB-where heen ging.
|
||||
if (m && m[1] === today) {
|
||||
const n = Number.parseInt(m[2], 10)
|
||||
if (!Number.isNaN(n) && n > max) max = n
|
||||
}
|
||||
|
|
@ -45,13 +47,58 @@ function isCodeUniqueConflict(error: unknown): boolean {
|
|||
return Array.isArray(target) ? target.includes('code') : target.includes('code')
|
||||
}
|
||||
|
||||
const inputSchema = z.object({
|
||||
export const inputSchema = z.object({
|
||||
product_id: z.string().min(1),
|
||||
code: z.string().min(1).max(30).optional(),
|
||||
sprint_goal: z.string().min(1).max(500),
|
||||
start_date: z.string().date().optional(),
|
||||
})
|
||||
|
||||
export async function handleCreateSprint(
|
||||
{ product_id, code, sprint_goal, start_date }: z.infer<typeof inputSchema>,
|
||||
) {
|
||||
return withToolErrors(async () => {
|
||||
const auth = await requireWriteAccess()
|
||||
if (!(await userCanAccessProduct(product_id, auth.userId))) {
|
||||
return toolError(`Product ${product_id} not found or not accessible`)
|
||||
}
|
||||
|
||||
const resolvedStartDate = start_date ? new Date(start_date) : new Date()
|
||||
const baseSelect = {
|
||||
id: true,
|
||||
code: true,
|
||||
sprint_goal: true,
|
||||
status: true,
|
||||
start_date: true,
|
||||
created_at: true,
|
||||
} as const
|
||||
|
||||
if (code) {
|
||||
const sprint = await prisma.sprint.create({
|
||||
data: { product_id, code, sprint_goal, status: 'OPEN', start_date: resolvedStartDate },
|
||||
select: baseSelect,
|
||||
})
|
||||
return toolJson(sprint)
|
||||
}
|
||||
|
||||
let lastError: unknown
|
||||
for (let attempt = 0; attempt < MAX_CODE_ATTEMPTS; attempt++) {
|
||||
const generated = await generateNextSprintCode(product_id)
|
||||
try {
|
||||
const sprint = await prisma.sprint.create({
|
||||
data: { product_id, code: generated, sprint_goal, status: 'OPEN', start_date: resolvedStartDate },
|
||||
select: baseSelect,
|
||||
})
|
||||
return toolJson(sprint)
|
||||
} catch (e) {
|
||||
if (isCodeUniqueConflict(e)) { lastError = e; continue }
|
||||
throw e
|
||||
}
|
||||
}
|
||||
throw lastError ?? new Error('Kon geen unieke sprint-code genereren')
|
||||
})
|
||||
}
|
||||
|
||||
export function registerCreateSprintTool(server: McpServer) {
|
||||
server.registerTool(
|
||||
'create_sprint',
|
||||
|
|
@ -61,64 +108,6 @@ export function registerCreateSprintTool(server: McpServer) {
|
|||
'Create a new sprint for a product with status=OPEN. Code auto-generated as S-{YYYY-MM-DD}-{N} per product per date if not provided. Forbidden for demo accounts.',
|
||||
inputSchema,
|
||||
},
|
||||
async ({ product_id, code, sprint_goal, start_date }) =>
|
||||
withToolErrors(async () => {
|
||||
const auth = await requireWriteAccess()
|
||||
if (!(await userCanAccessProduct(product_id, auth.userId))) {
|
||||
return toolError(`Product ${product_id} not found or not accessible`)
|
||||
}
|
||||
|
||||
const resolvedStartDate = start_date ? new Date(start_date) : new Date()
|
||||
|
||||
if (code) {
|
||||
const sprint = await prisma.sprint.create({
|
||||
data: {
|
||||
product_id,
|
||||
code,
|
||||
sprint_goal,
|
||||
status: 'OPEN',
|
||||
start_date: resolvedStartDate,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
sprint_goal: true,
|
||||
status: true,
|
||||
start_date: true,
|
||||
created_at: true,
|
||||
},
|
||||
})
|
||||
return toolJson(sprint)
|
||||
}
|
||||
|
||||
let lastError: unknown
|
||||
for (let attempt = 0; attempt < MAX_CODE_ATTEMPTS; attempt++) {
|
||||
const generated = await generateNextSprintCode(product_id)
|
||||
try {
|
||||
const sprint = await prisma.sprint.create({
|
||||
data: {
|
||||
product_id,
|
||||
code: generated,
|
||||
sprint_goal,
|
||||
status: 'OPEN',
|
||||
start_date: resolvedStartDate,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
sprint_goal: true,
|
||||
status: true,
|
||||
start_date: true,
|
||||
created_at: true,
|
||||
},
|
||||
})
|
||||
return toolJson(sprint)
|
||||
} catch (e) {
|
||||
if (isCodeUniqueConflict(e)) { lastError = e; continue }
|
||||
throw e
|
||||
}
|
||||
}
|
||||
throw lastError ?? new Error('Kon geen unieke sprint-code genereren')
|
||||
}),
|
||||
handleCreateSprint,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import { toolError, toolJson, withToolErrors } from '../errors.js'
|
|||
|
||||
const TERMINAL_STATUSES = new Set<SprintStatus>(['CLOSED', 'FAILED', 'ARCHIVED'])
|
||||
|
||||
const inputSchema = z.object({
|
||||
export const inputSchema = z.object({
|
||||
sprint_id: z.string().min(1),
|
||||
status: z.enum(['OPEN', 'CLOSED', 'ARCHIVED', 'FAILED']).optional(),
|
||||
sprint_goal: z.string().min(1).max(500).optional(),
|
||||
|
|
@ -24,6 +24,63 @@ const inputSchema = z.object({
|
|||
start_date: z.string().date().optional(),
|
||||
})
|
||||
|
||||
export async function handleUpdateSprint(
|
||||
{ sprint_id, status, sprint_goal, end_date, start_date }: z.infer<typeof inputSchema>,
|
||||
) {
|
||||
return withToolErrors(async () => {
|
||||
if (
|
||||
status === undefined &&
|
||||
sprint_goal === undefined &&
|
||||
end_date === undefined &&
|
||||
start_date === undefined
|
||||
) {
|
||||
return toolError('Minstens één veld vereist om te wijzigen')
|
||||
}
|
||||
|
||||
const auth = await requireWriteAccess()
|
||||
|
||||
const sprint = await prisma.sprint.findUnique({
|
||||
where: { id: sprint_id },
|
||||
select: { id: true, product_id: true },
|
||||
})
|
||||
if (!sprint) {
|
||||
return toolError(`Sprint ${sprint_id} not found`)
|
||||
}
|
||||
if (!(await userCanAccessProduct(sprint.product_id, auth.userId))) {
|
||||
return toolError(`Sprint ${sprint_id} not accessible`)
|
||||
}
|
||||
|
||||
const data: {
|
||||
status?: SprintStatus
|
||||
sprint_goal?: string
|
||||
start_date?: Date
|
||||
end_date?: Date
|
||||
} = {}
|
||||
if (status !== undefined) data.status = status
|
||||
if (sprint_goal !== undefined) data.sprint_goal = sprint_goal
|
||||
if (start_date !== undefined) data.start_date = new Date(start_date)
|
||||
if (end_date !== undefined) {
|
||||
data.end_date = new Date(end_date)
|
||||
} else if (status !== undefined && TERMINAL_STATUSES.has(status)) {
|
||||
data.end_date = new Date()
|
||||
}
|
||||
|
||||
const updated = await prisma.sprint.update({
|
||||
where: { id: sprint_id },
|
||||
data,
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
sprint_goal: true,
|
||||
status: true,
|
||||
start_date: true,
|
||||
end_date: true,
|
||||
},
|
||||
})
|
||||
return toolJson(updated)
|
||||
})
|
||||
}
|
||||
|
||||
export function registerUpdateSprintTool(server: McpServer) {
|
||||
server.registerTool(
|
||||
'update_sprint',
|
||||
|
|
@ -33,58 +90,6 @@ export function registerUpdateSprintTool(server: McpServer) {
|
|||
'Update a sprint: status, sprint_goal, start_date and/or end_date. At least one field required. No state-machine validation — last-write-wins. When status goes to CLOSED/FAILED/ARCHIVED and end_date is not provided, end_date is set to today. Forbidden for demo accounts.',
|
||||
inputSchema,
|
||||
},
|
||||
async ({ sprint_id, status, sprint_goal, end_date, start_date }) =>
|
||||
withToolErrors(async () => {
|
||||
if (
|
||||
status === undefined &&
|
||||
sprint_goal === undefined &&
|
||||
end_date === undefined &&
|
||||
start_date === undefined
|
||||
) {
|
||||
return toolError('Minstens één veld vereist om te wijzigen')
|
||||
}
|
||||
|
||||
const auth = await requireWriteAccess()
|
||||
|
||||
const sprint = await prisma.sprint.findUnique({
|
||||
where: { id: sprint_id },
|
||||
select: { id: true, product_id: true },
|
||||
})
|
||||
if (!sprint) {
|
||||
return toolError(`Sprint ${sprint_id} not found`)
|
||||
}
|
||||
if (!(await userCanAccessProduct(sprint.product_id, auth.userId))) {
|
||||
return toolError(`Sprint ${sprint_id} not accessible`)
|
||||
}
|
||||
|
||||
const data: {
|
||||
status?: SprintStatus
|
||||
sprint_goal?: string
|
||||
start_date?: Date
|
||||
end_date?: Date
|
||||
} = {}
|
||||
if (status !== undefined) data.status = status
|
||||
if (sprint_goal !== undefined) data.sprint_goal = sprint_goal
|
||||
if (start_date !== undefined) data.start_date = new Date(start_date)
|
||||
if (end_date !== undefined) {
|
||||
data.end_date = new Date(end_date)
|
||||
} else if (status !== undefined && TERMINAL_STATUSES.has(status)) {
|
||||
data.end_date = new Date()
|
||||
}
|
||||
|
||||
const updated = await prisma.sprint.update({
|
||||
where: { id: sprint_id },
|
||||
data,
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
sprint_goal: true,
|
||||
status: true,
|
||||
start_date: true,
|
||||
end_date: true,
|
||||
},
|
||||
})
|
||||
return toolJson(updated)
|
||||
}),
|
||||
handleUpdateSprint,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue