From 93d881318db6da7cc48bd759bf4e4043ae629c67 Mon Sep 17 00:00:00 2001 From: Janpeter Visser <30029041+madhura68@users.noreply.github.com> Date: Mon, 11 May 2026 21:37:05 +0200 Subject: [PATCH] feat(PBI-12): create_sprint + update_sprint MCP-tools (#47) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(PBI-12 T-51): voeg create_sprint tool toe Maakt een sprint aan met status=OPEN. Code auto-gegenereerd als S-{YYYY-MM-DD}-{N} per product per datum als niet meegegeven, met retry bij race-conflict op @@unique([product_id, code]). Volgt create-pbi.ts template. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(PBI-12 T-52): voeg update_sprint tool toe Generieke update voor status, sprint_goal, start_date en end_date. Géén state-machine validatie — last-write-wins. Bij status → CLOSED/FAILED/ARCHIVED zonder expliciete end_date wordt end_date automatisch op vandaag gezet. Minimaal één veld vereist (handmatige check in handler i.p.v. zod-refine want McpServer.inputSchema accepteert geen ZodEffects). Co-Authored-By: Claude Opus 4.7 (1M context) * 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) * chore: untrack .claude/worktrees gitlinks + ignore pad Per ongeluk in adbea3f meegenomen via 'git add -A'; deze embedded worktree- clones horen niet in de repo. Ook .gitignore aangevuld zodat dit niet opnieuw gebeurt. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(PBI-12): update_sprint zet completed_at op CLOSED — parity met cascade Codex-review op #47: bij status → CLOSED werd alleen end_date gezet, niet completed_at. Dat is divergeert van src/lib/tasks-status-update.ts dat completed_at = new Date() zet bij automatische sluiting via task-status- cascade. Reporting en UI die op completed_at filteren zagen handmatig gesloten sprints als 'never completed'. Fix: - update_sprint zet nu data.completed_at = new Date() wanneer status === 'CLOSED' - FAILED/ARCHIVED raken completed_at NIET (parity met bestaand patroon) - Test-coverage uitgebreid: - CLOSED zet end_date EN completed_at - FAILED zet end_date, completed_at blijft undefined - ARCHIVED zet end_date, completed_at blijft undefined - OPEN zet noch end_date noch completed_at - Expliciete end_date wordt gerespecteerd, completed_at wordt nog steeds gezet - Tool description vermeldt nu de completed_at-side-effect Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Madhura68 Co-authored-by: Claude Opus 4.7 (1M context) --- .gitignore | 3 + __tests__/create-sprint.test.ts | 163 ++++++++++++++++++++++++++++++ __tests__/update-sprint.test.ts | 174 ++++++++++++++++++++++++++++++++ src/index.ts | 5 + src/tools/create-sprint.ts | 113 +++++++++++++++++++++ src/tools/update-sprint.ts | 102 +++++++++++++++++++ 6 files changed, 560 insertions(+) create mode 100644 __tests__/create-sprint.test.ts create mode 100644 __tests__/update-sprint.test.ts create mode 100644 src/tools/create-sprint.ts create mode 100644 src/tools/update-sprint.ts diff --git a/.gitignore b/.gitignore index 10a6dab..547c38e 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,6 @@ prisma/generated # Editor .vscode .idea + +# Claude Code worktrees (per-session, never tracked) +.claude/worktrees/ diff --git a/__tests__/create-sprint.test.ts b/__tests__/create-sprint.test.ts new file mode 100644 index 0000000..72d400d --- /dev/null +++ b/__tests__/create-sprint.test.ts @@ -0,0 +1,163 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { Prisma } from '@prisma/client' + +vi.mock('../src/prisma.js', () => ({ + prisma: { + sprint: { + findMany: vi.fn(), + create: vi.fn(), + }, + }, +})) + +vi.mock('../src/auth.js', () => ({ + requireWriteAccess: vi.fn(), + PermissionDeniedError: class PermissionDeniedError extends Error { + constructor(message = 'Demo accounts cannot perform write operations') { + super(message) + this.name = 'PermissionDeniedError' + } + }, +})) + +vi.mock('../src/access.js', () => ({ + userCanAccessProduct: vi.fn(), +})) + +import { prisma } from '../src/prisma.js' +import { requireWriteAccess } from '../src/auth.js' +import { userCanAccessProduct } from '../src/access.js' +import { handleCreateSprint } from '../src/tools/create-sprint.js' + +const mockPrisma = prisma as unknown as { + sprint: { + findMany: ReturnType + create: ReturnType + } +} +const mockRequireWriteAccess = requireWriteAccess as ReturnType +const mockUserCanAccessProduct = userCanAccessProduct as ReturnType + +const PRODUCT_ID = 'prod-1' +const USER_ID = 'user-1' + +beforeEach(() => { + vi.clearAllMocks() + mockRequireWriteAccess.mockResolvedValue({ userId: USER_ID, tokenId: 'tok-1', username: 'alice', isDemo: false }) + mockUserCanAccessProduct.mockResolvedValue(true) + mockPrisma.sprint.findMany.mockResolvedValue([]) +}) + +function parseResult(result: Awaited>) { + const text = result.content?.[0]?.type === 'text' ? result.content[0].text : '' + try { return JSON.parse(text) } catch { return text } +} + +describe('handleCreateSprint', () => { + it('happy path: creates sprint with auto-generated code', async () => { + mockPrisma.sprint.create.mockResolvedValue({ + id: 'spr-1', + code: 'S-2026-05-11-1', + sprint_goal: 'My goal', + status: 'OPEN', + start_date: new Date('2026-05-11'), + created_at: new Date('2026-05-11T10:00:00Z'), + }) + + const result = await handleCreateSprint({ + product_id: PRODUCT_ID, + sprint_goal: 'My goal', + }) + + expect(mockPrisma.sprint.create).toHaveBeenCalledTimes(1) + const callArgs = mockPrisma.sprint.create.mock.calls[0][0] + expect(callArgs.data.product_id).toBe(PRODUCT_ID) + expect(callArgs.data.status).toBe('OPEN') + expect(callArgs.data.sprint_goal).toBe('My goal') + expect(callArgs.data.code).toMatch(/^S-\d{4}-\d{2}-\d{2}-1$/) + expect(callArgs.data.start_date).toBeInstanceOf(Date) + + const parsed = parseResult(result) + expect(parsed.id).toBe('spr-1') + expect(parsed.status).toBe('OPEN') + }) + + it('uses user-provided code when given', async () => { + mockPrisma.sprint.create.mockResolvedValue({ + id: 'spr-2', + code: 'CUSTOM-CODE', + sprint_goal: 'g', + status: 'OPEN', + start_date: new Date(), + created_at: new Date(), + }) + + await handleCreateSprint({ + product_id: PRODUCT_ID, + code: 'CUSTOM-CODE', + sprint_goal: 'g', + }) + + expect(mockPrisma.sprint.create).toHaveBeenCalledTimes(1) + expect(mockPrisma.sprint.findMany).not.toHaveBeenCalled() + expect(mockPrisma.sprint.create.mock.calls[0][0].data.code).toBe('CUSTOM-CODE') + }) + + it('auto-code increments past existing same-day sprints', async () => { + mockPrisma.sprint.findMany.mockResolvedValue([ + { code: 'S-2026-05-11-1' }, + { code: 'S-2026-05-11-3' }, + { code: 'S-2026-05-10-7' }, + ]) + mockPrisma.sprint.create.mockResolvedValue({ + id: 'spr-3', code: 'X', sprint_goal: 'g', status: 'OPEN', start_date: new Date(), created_at: new Date(), + }) + + await handleCreateSprint({ product_id: PRODUCT_ID, sprint_goal: 'g' }) + + const today = new Date().toISOString().slice(0, 10) + expect(mockPrisma.sprint.create.mock.calls[0][0].data.code).toBe(`S-${today}-4`) + }) + + it('retries on P2002 unique conflict', async () => { + const conflict = new Prisma.PrismaClientKnownRequestError('unique', { + code: 'P2002', clientVersion: 'x', meta: { target: ['product_id', 'code'] }, + }) + mockPrisma.sprint.create + .mockRejectedValueOnce(conflict) + .mockResolvedValueOnce({ + id: 'spr-r', code: 'S-2026-05-11-2', sprint_goal: 'g', status: 'OPEN', + start_date: new Date(), created_at: new Date(), + }) + + const result = await handleCreateSprint({ product_id: PRODUCT_ID, sprint_goal: 'g' }) + + expect(mockPrisma.sprint.create).toHaveBeenCalledTimes(2) + expect(parseResult(result).id).toBe('spr-r') + }) + + it('returns error when user cannot access product', async () => { + mockUserCanAccessProduct.mockResolvedValue(false) + const result = await handleCreateSprint({ product_id: PRODUCT_ID, sprint_goal: 'g' }) + + expect(mockPrisma.sprint.create).not.toHaveBeenCalled() + const text = result.content?.[0]?.type === 'text' ? result.content[0].text : '' + expect(text).toMatch(/not found or not accessible/) + }) + + it('uses provided start_date when given', async () => { + mockPrisma.sprint.create.mockResolvedValue({ + id: 'spr-d', code: 'X', sprint_goal: 'g', status: 'OPEN', + start_date: new Date('2026-01-01'), created_at: new Date(), + }) + + await handleCreateSprint({ + product_id: PRODUCT_ID, + sprint_goal: 'g', + start_date: '2026-01-01', + }) + + const callArgs = mockPrisma.sprint.create.mock.calls[0][0] + expect(callArgs.data.start_date.toISOString().slice(0, 10)).toBe('2026-01-01') + }) +}) diff --git a/__tests__/update-sprint.test.ts b/__tests__/update-sprint.test.ts new file mode 100644 index 0000000..3c62790 --- /dev/null +++ b/__tests__/update-sprint.test.ts @@ -0,0 +1,174 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('../src/prisma.js', () => ({ + prisma: { + sprint: { + findUnique: vi.fn(), + update: vi.fn(), + }, + }, +})) + +vi.mock('../src/auth.js', () => ({ + requireWriteAccess: vi.fn(), + PermissionDeniedError: class PermissionDeniedError extends Error { + constructor(message = 'Demo accounts cannot perform write operations') { + super(message) + this.name = 'PermissionDeniedError' + } + }, +})) + +vi.mock('../src/access.js', () => ({ + userCanAccessProduct: vi.fn(), +})) + +import { prisma } from '../src/prisma.js' +import { requireWriteAccess } from '../src/auth.js' +import { userCanAccessProduct } from '../src/access.js' +import { handleUpdateSprint } from '../src/tools/update-sprint.js' + +const mockPrisma = prisma as unknown as { + sprint: { + findUnique: ReturnType + update: ReturnType + } +} +const mockRequireWriteAccess = requireWriteAccess as ReturnType +const mockUserCanAccessProduct = userCanAccessProduct as ReturnType + +const SPRINT_ID = 'spr-1' +const PRODUCT_ID = 'prod-1' +const USER_ID = 'user-1' + +beforeEach(() => { + vi.clearAllMocks() + mockRequireWriteAccess.mockResolvedValue({ userId: USER_ID, tokenId: 'tok-1', username: 'alice', isDemo: false }) + mockUserCanAccessProduct.mockResolvedValue(true) + mockPrisma.sprint.findUnique.mockResolvedValue({ id: SPRINT_ID, product_id: PRODUCT_ID }) + mockPrisma.sprint.update.mockResolvedValue({ + id: SPRINT_ID, + code: 'S-2026-05-11-1', + sprint_goal: 'g', + status: 'OPEN', + start_date: new Date('2026-05-11'), + end_date: null, + completed_at: null, + }) +}) + +function getText(result: Awaited>) { + return result.content?.[0]?.type === 'text' ? result.content[0].text : '' +} + +describe('handleUpdateSprint', () => { + it('returns error when no fields provided', async () => { + const result = await handleUpdateSprint({ sprint_id: SPRINT_ID }) + + expect(mockPrisma.sprint.update).not.toHaveBeenCalled() + expect(getText(result)).toMatch(/Minstens één veld vereist/) + }) + + it('updates status only', async () => { + await handleUpdateSprint({ sprint_id: SPRINT_ID, status: 'OPEN' }) + + expect(mockPrisma.sprint.update).toHaveBeenCalledTimes(1) + const args = mockPrisma.sprint.update.mock.calls[0][0] + expect(args.where).toEqual({ id: SPRINT_ID }) + expect(args.data).toEqual({ status: 'OPEN' }) + }) + + it('auto-sets end_date AND completed_at when status → CLOSED without explicit end_date', async () => { + const before = Date.now() + await handleUpdateSprint({ sprint_id: SPRINT_ID, status: 'CLOSED' }) + const after = Date.now() + + const args = mockPrisma.sprint.update.mock.calls[0][0] + expect(args.data.status).toBe('CLOSED') + expect(args.data.end_date).toBeInstanceOf(Date) + expect(args.data.end_date.getTime()).toBeGreaterThanOrEqual(before) + expect(args.data.end_date.getTime()).toBeLessThanOrEqual(after) + expect(args.data.completed_at).toBeInstanceOf(Date) + expect(args.data.completed_at.getTime()).toBeGreaterThanOrEqual(before) + expect(args.data.completed_at.getTime()).toBeLessThanOrEqual(after) + }) + + it('auto-sets end_date when status → FAILED, but does NOT set completed_at', async () => { + await handleUpdateSprint({ sprint_id: SPRINT_ID, status: 'FAILED' }) + + const args = mockPrisma.sprint.update.mock.calls[0][0] + expect(args.data.end_date).toBeInstanceOf(Date) + expect(args.data.completed_at).toBeUndefined() + }) + + it('auto-sets end_date when status → ARCHIVED, but does NOT set completed_at', async () => { + await handleUpdateSprint({ sprint_id: SPRINT_ID, status: 'ARCHIVED' }) + + const args = mockPrisma.sprint.update.mock.calls[0][0] + expect(args.data.end_date).toBeInstanceOf(Date) + expect(args.data.completed_at).toBeUndefined() + }) + + it('still sets completed_at when status → CLOSED even with explicit end_date', async () => { + await handleUpdateSprint({ + sprint_id: SPRINT_ID, + status: 'CLOSED', + end_date: '2025-12-31', + }) + + const args = mockPrisma.sprint.update.mock.calls[0][0] + expect(args.data.end_date.toISOString().slice(0, 10)).toBe('2025-12-31') + expect(args.data.completed_at).toBeInstanceOf(Date) + }) + + it('does NOT auto-set end_date or completed_at when status → OPEN', async () => { + await handleUpdateSprint({ sprint_id: SPRINT_ID, status: 'OPEN' }) + + const args = mockPrisma.sprint.update.mock.calls[0][0] + expect(args.data.end_date).toBeUndefined() + expect(args.data.completed_at).toBeUndefined() + }) + + it('updates multiple fields at once', async () => { + await handleUpdateSprint({ + sprint_id: SPRINT_ID, + sprint_goal: 'New goal', + start_date: '2026-05-15', + }) + + const args = mockPrisma.sprint.update.mock.calls[0][0] + expect(args.data.sprint_goal).toBe('New goal') + expect(args.data.start_date.toISOString().slice(0, 10)).toBe('2026-05-15') + expect(args.data.status).toBeUndefined() + expect(args.data.end_date).toBeUndefined() + }) + + it('returns error when sprint not found', async () => { + mockPrisma.sprint.findUnique.mockResolvedValue(null) + + const result = await handleUpdateSprint({ sprint_id: SPRINT_ID, status: 'CLOSED' }) + + expect(mockPrisma.sprint.update).not.toHaveBeenCalled() + expect(getText(result)).toMatch(/not found/) + }) + + it('returns error when user cannot access sprint product', async () => { + mockUserCanAccessProduct.mockResolvedValue(false) + + const result = await handleUpdateSprint({ sprint_id: SPRINT_ID, status: 'CLOSED' }) + + expect(mockPrisma.sprint.update).not.toHaveBeenCalled() + expect(getText(result)).toMatch(/not accessible/) + }) + + it('allows any status transition (no state-machine)', async () => { + await handleUpdateSprint({ sprint_id: SPRINT_ID, status: 'OPEN' }) + expect(mockPrisma.sprint.update).toHaveBeenCalledTimes(1) + + await handleUpdateSprint({ sprint_id: SPRINT_ID, status: 'CLOSED' }) + expect(mockPrisma.sprint.update).toHaveBeenCalledTimes(2) + + await handleUpdateSprint({ sprint_id: SPRINT_ID, status: 'OPEN' }) + expect(mockPrisma.sprint.update).toHaveBeenCalledTimes(3) + }) +}) diff --git a/src/index.ts b/src/index.ts index 2938c70..06cefba 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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) diff --git a/src/tools/create-sprint.ts b/src/tools/create-sprint.ts new file mode 100644 index 0000000..5d8cd9b --- /dev/null +++ b/src/tools/create-sprint.ts @@ -0,0 +1,113 @@ +// MCP authoring tool: create een Sprint binnen een product. +// +// Status start altijd op OPEN; geen reuse-check op bestaande OPEN-sprints +// (per plan-to-pbi-flow.md "altijd nieuwe sprint"). Code wordt auto-gegenereerd +// als S-{YYYY-MM-DD}-{N} per product per datum, met retry bij race-condition +// op de unique constraint (@@unique([product_id, code])). + +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 SPRINT_AUTO_RE = /^S-(\d{4}-\d{2}-\d{2})-(\d+)$/ +const MAX_CODE_ATTEMPTS = 3 + +function todayIsoDate(): string { + return new Date().toISOString().slice(0, 10) +} + +async function generateNextSprintCode(productId: string): Promise { + const today = todayIsoDate() + const sprints = await prisma.sprint.findMany({ + where: { product_id: productId, code: { startsWith: `S-${today}-` } }, + select: { code: true }, + }) + let max = 0 + for (const s of sprints) { + const m = s.code?.match(SPRINT_AUTO_RE) + // 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 + } + } + return `S-${today}-${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') +} + +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, +) { + 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', + { + title: 'Create Sprint', + description: + '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, + }, + handleCreateSprint, + ) +} diff --git a/src/tools/update-sprint.ts b/src/tools/update-sprint.ts new file mode 100644 index 0000000..04800e3 --- /dev/null +++ b/src/tools/update-sprint.ts @@ -0,0 +1,102 @@ +// MCP tool: update een Sprint. +// +// Generieke update — wijzigt elke combinatie van status, sprint_goal, +// start_date en end_date. Géén state-machine validatie (zie +// docs/plans/sprint-mcp-tools.md): last-write-wins, het resubmit/heropen-pad +// zit elders. Bij status → CLOSED/FAILED/ARCHIVED zonder expliciete end_date +// wordt end_date automatisch op vandaag gezet. Bij status → CLOSED wordt +// daarnaast `completed_at` op now() gezet (parity met +// src/lib/tasks-status-update.ts dat hetzelfde doet bij auto-close via +// task-status-cascade; zo houden reporting en UI één bron van waarheid voor +// completion-tijd). + +import { z } from 'zod' +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import type { SprintStatus } 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 TERMINAL_STATUSES = new Set(['CLOSED', 'FAILED', 'ARCHIVED']) + +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(), + end_date: z.string().date().optional(), + start_date: z.string().date().optional(), +}) + +export async function handleUpdateSprint( + { sprint_id, status, sprint_goal, end_date, start_date }: z.infer, +) { + 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 + completed_at?: 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() + } + if (status === 'CLOSED') data.completed_at = 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, + completed_at: true, + }, + }) + return toolJson(updated) + }) +} + +export function registerUpdateSprintTool(server: McpServer) { + server.registerTool( + 'update_sprint', + { + title: 'Update Sprint', + description: + '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. When status goes to CLOSED, completed_at is set to now (parity with auto-close via task-cascade). Forbidden for demo accounts.', + inputSchema, + }, + handleUpdateSprint, + ) +}