From adbea3fd9a7682ef32982bfecd41c0e424587ab0 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Mon, 11 May 2026 21:06:56 +0200 Subject: [PATCH] feat(PBI-12 T-53): registreer sprint-tools + unit-tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .claude/worktrees/relaxed-kowalevski-729c1a | 1 + .claude/worktrees/youthful-dewdney-c695f6 | 1 + __tests__/create-sprint.test.ts | 163 ++++++++++++++++++++ __tests__/update-sprint.test.ts | 163 ++++++++++++++++++++ src/index.ts | 5 + src/tools/create-sprint.ts | 111 ++++++------- src/tools/update-sprint.ts | 113 +++++++------- 7 files changed, 442 insertions(+), 115 deletions(-) create mode 160000 .claude/worktrees/relaxed-kowalevski-729c1a create mode 160000 .claude/worktrees/youthful-dewdney-c695f6 create mode 100644 __tests__/create-sprint.test.ts create mode 100644 __tests__/update-sprint.test.ts diff --git a/.claude/worktrees/relaxed-kowalevski-729c1a b/.claude/worktrees/relaxed-kowalevski-729c1a new file mode 160000 index 0000000..9ffa25f --- /dev/null +++ b/.claude/worktrees/relaxed-kowalevski-729c1a @@ -0,0 +1 @@ +Subproject commit 9ffa25f0536cebe328a8abf9d2b24c43509a13ca diff --git a/.claude/worktrees/youthful-dewdney-c695f6 b/.claude/worktrees/youthful-dewdney-c695f6 new file mode 160000 index 0000000..9ffa25f --- /dev/null +++ b/.claude/worktrees/youthful-dewdney-c695f6 @@ -0,0 +1 @@ +Subproject commit 9ffa25f0536cebe328a8abf9d2b24c43509a13ca 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..ac4d04f --- /dev/null +++ b/__tests__/update-sprint.test.ts @@ -0,0 +1,163 @@ +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, + }) +}) + +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 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) + }) + + it('auto-sets end_date when status → FAILED', async () => { + await handleUpdateSprint({ sprint_id: SPRINT_ID, status: 'FAILED' }) + + expect(mockPrisma.sprint.update.mock.calls[0][0].data.end_date).toBeInstanceOf(Date) + }) + + it('auto-sets end_date when status → ARCHIVED', async () => { + await handleUpdateSprint({ sprint_id: SPRINT_ID, status: 'ARCHIVED' }) + + expect(mockPrisma.sprint.update.mock.calls[0][0].data.end_date).toBeInstanceOf(Date) + }) + + it('does NOT auto-set end_date when status → OPEN', async () => { + await handleUpdateSprint({ sprint_id: SPRINT_ID, status: 'OPEN' }) + + expect(mockPrisma.sprint.update.mock.calls[0][0].data.end_date).toBeUndefined() + }) + + it('respects explicit end_date when status is terminal', 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') + }) + + 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 index f16bca6..5d8cd9b 100644 --- a/src/tools/create-sprint.ts +++ b/src/tools/create-sprint.ts @@ -29,7 +29,9 @@ async function generateNextSprintCode(productId: string): Promise { 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, +) { + 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, ) } diff --git a/src/tools/update-sprint.ts b/src/tools/update-sprint.ts index f9ca411..c215c5c 100644 --- a/src/tools/update-sprint.ts +++ b/src/tools/update-sprint.ts @@ -16,7 +16,7 @@ import { toolError, toolJson, withToolErrors } from '../errors.js' const TERMINAL_STATUSES = new Set(['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, +) { + 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, ) }