feat(PBI-12): create_sprint + update_sprint MCP-tools (#47)
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Madhura68 <ID+Madhura68@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9ffa25f053
commit
93d881318d
6 changed files with 560 additions and 0 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -12,3 +12,6 @@ prisma/generated
|
||||||
# Editor
|
# Editor
|
||||||
.vscode
|
.vscode
|
||||||
.idea
|
.idea
|
||||||
|
|
||||||
|
# Claude Code worktrees (per-session, never tracked)
|
||||||
|
.claude/worktrees/
|
||||||
|
|
|
||||||
163
__tests__/create-sprint.test.ts
Normal file
163
__tests__/create-sprint.test.ts
Normal file
|
|
@ -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<typeof vi.fn>
|
||||||
|
create: ReturnType<typeof vi.fn>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const mockRequireWriteAccess = requireWriteAccess as ReturnType<typeof vi.fn>
|
||||||
|
const mockUserCanAccessProduct = userCanAccessProduct as ReturnType<typeof vi.fn>
|
||||||
|
|
||||||
|
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<ReturnType<typeof handleCreateSprint>>) {
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
174
__tests__/update-sprint.test.ts
Normal file
174
__tests__/update-sprint.test.ts
Normal file
|
|
@ -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<typeof vi.fn>
|
||||||
|
update: ReturnType<typeof vi.fn>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const mockRequireWriteAccess = requireWriteAccess as ReturnType<typeof vi.fn>
|
||||||
|
const mockUserCanAccessProduct = userCanAccessProduct as ReturnType<typeof vi.fn>
|
||||||
|
|
||||||
|
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<ReturnType<typeof handleUpdateSprint>>) {
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -12,6 +12,8 @@ import { registerLogCommitTool } from './tools/log-commit.js'
|
||||||
import { registerCreatePbiTool } from './tools/create-pbi.js'
|
import { registerCreatePbiTool } from './tools/create-pbi.js'
|
||||||
import { registerCreateStoryTool } from './tools/create-story.js'
|
import { registerCreateStoryTool } from './tools/create-story.js'
|
||||||
import { registerCreateTaskTool } from './tools/create-task.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 { registerAskUserQuestionTool } from './tools/ask-user-question.js'
|
||||||
import { registerGetQuestionAnswerTool } from './tools/get-question-answer.js'
|
import { registerGetQuestionAnswerTool } from './tools/get-question-answer.js'
|
||||||
import { registerListOpenQuestionsTool } from './tools/list-open-questions.js'
|
import { registerListOpenQuestionsTool } from './tools/list-open-questions.js'
|
||||||
|
|
@ -77,6 +79,9 @@ async function main() {
|
||||||
registerCreatePbiTool(server)
|
registerCreatePbiTool(server)
|
||||||
registerCreateStoryTool(server)
|
registerCreateStoryTool(server)
|
||||||
registerCreateTaskTool(server)
|
registerCreateTaskTool(server)
|
||||||
|
// PBI-12: sprint lifecycle tools
|
||||||
|
registerCreateSprintTool(server)
|
||||||
|
registerUpdateSprintTool(server)
|
||||||
registerAskUserQuestionTool(server)
|
registerAskUserQuestionTool(server)
|
||||||
registerGetQuestionAnswerTool(server)
|
registerGetQuestionAnswerTool(server)
|
||||||
registerListOpenQuestionsTool(server)
|
registerListOpenQuestionsTool(server)
|
||||||
|
|
|
||||||
113
src/tools/create-sprint.ts
Normal file
113
src/tools/create-sprint.ts
Normal file
|
|
@ -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<string> {
|
||||||
|
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<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',
|
||||||
|
{
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
102
src/tools/update-sprint.ts
Normal file
102
src/tools/update-sprint.ts
Normal file
|
|
@ -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<SprintStatus>(['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<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
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue