feat(PBI-79/ST-1343): sprint-conflicts helper-library
- lib/sprint-conflicts.ts: drie pure/server-side helpers voor eligibility
+ cross-sprint detectie.
- isEligibleForSprint(story): sprint_id IS NULL en status != DONE
- partitionByEligibility(prisma, storyIds, excludeSprintId): split in
eligible / notEligible / crossSprint met reden per story
- getBlockingSprintMap(prisma, productId, storyIds, excludeSprintId):
map storyId → { sprintId, sprintName } voor stories in andere OPEN sprint
- Tests: __tests__/lib/sprint-conflicts.test.ts (16 cases) — alle eligibility
paden + cross-sprint scoping + CLOSED-sprint filtering.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
56c55e1813
commit
b4a515e86e
2 changed files with 311 additions and 0 deletions
195
__tests__/lib/sprint-conflicts.test.ts
Normal file
195
__tests__/lib/sprint-conflicts.test.ts
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
import { describe, it, expect, vi } from 'vitest'
|
||||
import type { StoryStatus } from '@prisma/client'
|
||||
|
||||
import {
|
||||
getBlockingSprintMap,
|
||||
isEligibleForSprint,
|
||||
partitionByEligibility,
|
||||
} from '@/lib/sprint-conflicts'
|
||||
|
||||
function mockPrisma(stories: Array<Record<string, unknown>>) {
|
||||
return {
|
||||
story: {
|
||||
findMany: vi.fn().mockResolvedValue(stories),
|
||||
},
|
||||
} as unknown as Parameters<typeof partitionByEligibility>[0]
|
||||
}
|
||||
|
||||
describe('isEligibleForSprint', () => {
|
||||
it('returns true for OPEN story without sprint', () => {
|
||||
expect(
|
||||
isEligibleForSprint({ sprint_id: null, status: 'OPEN' as StoryStatus }),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for IN_SPRINT story without sprint_id (edge: restoration)', () => {
|
||||
expect(
|
||||
isEligibleForSprint({
|
||||
sprint_id: null,
|
||||
status: 'IN_SPRINT' as StoryStatus,
|
||||
}),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for DONE story without sprint', () => {
|
||||
expect(
|
||||
isEligibleForSprint({ sprint_id: null, status: 'DONE' as StoryStatus }),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when story is in any sprint (open status)', () => {
|
||||
expect(
|
||||
isEligibleForSprint({
|
||||
sprint_id: 'abc',
|
||||
status: 'OPEN' as StoryStatus,
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when story is in any sprint (done status)', () => {
|
||||
expect(
|
||||
isEligibleForSprint({
|
||||
sprint_id: 'abc',
|
||||
status: 'DONE' as StoryStatus,
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('partitionByEligibility', () => {
|
||||
it('returns empty partition for empty input', async () => {
|
||||
const prisma = mockPrisma([])
|
||||
const result = await partitionByEligibility(prisma, [])
|
||||
expect(result).toEqual({ eligible: [], notEligible: [], crossSprint: [] })
|
||||
})
|
||||
|
||||
it('classifies all eligible when stories are free + OPEN', async () => {
|
||||
const prisma = mockPrisma([
|
||||
{ id: 's1', sprint_id: null, status: 'OPEN', sprint: null },
|
||||
{ id: 's2', sprint_id: null, status: 'IN_SPRINT', sprint: null },
|
||||
])
|
||||
const result = await partitionByEligibility(prisma, ['s1', 's2'])
|
||||
expect(result.eligible).toEqual(['s1', 's2'])
|
||||
expect(result.notEligible).toEqual([])
|
||||
expect(result.crossSprint).toEqual([])
|
||||
})
|
||||
|
||||
it('marks DONE stories as notEligible with reason=DONE', async () => {
|
||||
const prisma = mockPrisma([
|
||||
{ id: 's1', sprint_id: null, status: 'DONE', sprint: null },
|
||||
])
|
||||
const result = await partitionByEligibility(prisma, ['s1'])
|
||||
expect(result.eligible).toEqual([])
|
||||
expect(result.notEligible).toEqual([{ storyId: 's1', reason: 'DONE' }])
|
||||
})
|
||||
|
||||
it('marks stories in other OPEN sprint as crossSprint + notEligible', async () => {
|
||||
const prisma = mockPrisma([
|
||||
{
|
||||
id: 's1',
|
||||
sprint_id: 'sprint-other',
|
||||
status: 'IN_SPRINT',
|
||||
sprint: { id: 'sprint-other', code: 'SP-2', status: 'OPEN' },
|
||||
},
|
||||
])
|
||||
const result = await partitionByEligibility(prisma, ['s1'])
|
||||
expect(result.crossSprint).toEqual([
|
||||
{ storyId: 's1', sprintId: 'sprint-other', sprintName: 'SP-2' },
|
||||
])
|
||||
expect(result.notEligible).toEqual([
|
||||
{ storyId: 's1', reason: 'IN_OTHER_SPRINT' },
|
||||
])
|
||||
expect(result.eligible).toEqual([])
|
||||
})
|
||||
|
||||
it('classifies story in CLOSED sprint with status=OPEN as eligible (status reset already happened)', async () => {
|
||||
const prisma = mockPrisma([
|
||||
{
|
||||
id: 's1',
|
||||
sprint_id: null,
|
||||
status: 'OPEN',
|
||||
sprint: null,
|
||||
},
|
||||
])
|
||||
const result = await partitionByEligibility(prisma, ['s1'])
|
||||
expect(result.eligible).toEqual(['s1'])
|
||||
})
|
||||
|
||||
it('does NOT mark crossSprint for stories in CLOSED other sprint', async () => {
|
||||
const prisma = mockPrisma([
|
||||
{
|
||||
id: 's1',
|
||||
sprint_id: 'sprint-closed',
|
||||
status: 'DONE',
|
||||
sprint: { id: 'sprint-closed', code: 'SP-C', status: 'CLOSED' },
|
||||
},
|
||||
])
|
||||
const result = await partitionByEligibility(prisma, ['s1'])
|
||||
expect(result.crossSprint).toEqual([])
|
||||
expect(result.notEligible).toEqual([
|
||||
{ storyId: 's1', reason: 'IN_OTHER_SPRINT' },
|
||||
])
|
||||
})
|
||||
|
||||
it('respects excludeSprintId — story in same sprint is eligible', async () => {
|
||||
const prisma = mockPrisma([
|
||||
{
|
||||
id: 's1',
|
||||
sprint_id: 'sprint-active',
|
||||
status: 'IN_SPRINT',
|
||||
sprint: { id: 'sprint-active', code: 'SP-A', status: 'OPEN' },
|
||||
},
|
||||
])
|
||||
const result = await partitionByEligibility(prisma, ['s1'], 'sprint-active')
|
||||
expect(result.eligible).toEqual(['s1'])
|
||||
expect(result.crossSprint).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('getBlockingSprintMap', () => {
|
||||
it('returns empty map for empty input', async () => {
|
||||
const prisma = mockPrisma([])
|
||||
const result = await getBlockingSprintMap(prisma, 'p1', [])
|
||||
expect(result.size).toBe(0)
|
||||
})
|
||||
|
||||
it('returns blocking sprint info for stories in OPEN sprints', async () => {
|
||||
const prisma = mockPrisma([
|
||||
{
|
||||
id: 's1',
|
||||
sprint_id: 'sprint-x',
|
||||
sprint: { id: 'sprint-x', code: 'SP-X', status: 'OPEN' },
|
||||
},
|
||||
])
|
||||
const result = await getBlockingSprintMap(prisma, 'p1', ['s1'])
|
||||
expect(result.get('s1')).toEqual({
|
||||
sprintId: 'sprint-x',
|
||||
sprintName: 'SP-X',
|
||||
})
|
||||
})
|
||||
|
||||
it('excludes the active sprint from blocking', async () => {
|
||||
const prisma = mockPrisma([
|
||||
{
|
||||
id: 's1',
|
||||
sprint_id: 'sprint-active',
|
||||
sprint: { id: 'sprint-active', code: 'SP-A', status: 'OPEN' },
|
||||
},
|
||||
])
|
||||
const result = await getBlockingSprintMap(
|
||||
prisma,
|
||||
'p1',
|
||||
['s1'],
|
||||
'sprint-active',
|
||||
)
|
||||
expect(result.size).toBe(0)
|
||||
})
|
||||
|
||||
it('does not include CLOSED sprints (filtered at DB query level)', async () => {
|
||||
// The prisma mock receives WHERE sprint.status='OPEN' so CLOSED stories
|
||||
// are already filtered out before reaching this function's mapping logic.
|
||||
const prisma = mockPrisma([])
|
||||
const result = await getBlockingSprintMap(prisma, 'p1', ['s1'])
|
||||
expect(result.size).toBe(0)
|
||||
})
|
||||
})
|
||||
116
lib/sprint-conflicts.ts
Normal file
116
lib/sprint-conflicts.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import type { Prisma, PrismaClient, StoryStatus } from '@prisma/client'
|
||||
|
||||
export type EligibilityReason = 'DONE' | 'IN_OTHER_SPRINT'
|
||||
|
||||
export type CrossSprintBlock = {
|
||||
storyId: string
|
||||
sprintId: string
|
||||
sprintName: string
|
||||
}
|
||||
|
||||
export type EligibilityPartition = {
|
||||
eligible: string[]
|
||||
notEligible: { storyId: string; reason: EligibilityReason }[]
|
||||
crossSprint: CrossSprintBlock[]
|
||||
}
|
||||
|
||||
type StoryEligibilityInput = {
|
||||
sprint_id: string | null
|
||||
status: StoryStatus
|
||||
}
|
||||
|
||||
export function isEligibleForSprint(story: StoryEligibilityInput): boolean {
|
||||
return story.sprint_id === null && story.status !== 'DONE'
|
||||
}
|
||||
|
||||
type PrismaLike = Pick<PrismaClient, 'story'> | Prisma.TransactionClient
|
||||
|
||||
export async function partitionByEligibility(
|
||||
prisma: PrismaLike,
|
||||
storyIds: string[],
|
||||
excludeSprintId?: string,
|
||||
): Promise<EligibilityPartition> {
|
||||
if (storyIds.length === 0) {
|
||||
return { eligible: [], notEligible: [], crossSprint: [] }
|
||||
}
|
||||
|
||||
const stories = await prisma.story.findMany({
|
||||
where: { id: { in: storyIds } },
|
||||
select: {
|
||||
id: true,
|
||||
sprint_id: true,
|
||||
status: true,
|
||||
sprint: { select: { id: true, code: true, status: true } },
|
||||
},
|
||||
})
|
||||
|
||||
const eligible: string[] = []
|
||||
const notEligible: { storyId: string; reason: EligibilityReason }[] = []
|
||||
const crossSprint: CrossSprintBlock[] = []
|
||||
|
||||
for (const story of stories) {
|
||||
const inOtherSprint = story.sprint_id !== null && story.sprint_id !== excludeSprintId
|
||||
const inSameSprint = excludeSprintId !== undefined && story.sprint_id === excludeSprintId
|
||||
|
||||
if (inOtherSprint) {
|
||||
if (story.sprint && story.sprint.status === 'OPEN') {
|
||||
crossSprint.push({
|
||||
storyId: story.id,
|
||||
sprintId: story.sprint.id,
|
||||
sprintName: story.sprint.code,
|
||||
})
|
||||
}
|
||||
notEligible.push({ storyId: story.id, reason: 'IN_OTHER_SPRINT' })
|
||||
continue
|
||||
}
|
||||
|
||||
if (story.status === 'DONE') {
|
||||
notEligible.push({ storyId: story.id, reason: 'DONE' })
|
||||
continue
|
||||
}
|
||||
|
||||
if (inSameSprint) {
|
||||
eligible.push(story.id)
|
||||
continue
|
||||
}
|
||||
|
||||
eligible.push(story.id)
|
||||
}
|
||||
|
||||
return { eligible, notEligible, crossSprint }
|
||||
}
|
||||
|
||||
export async function getBlockingSprintMap(
|
||||
prisma: PrismaLike,
|
||||
productId: string,
|
||||
storyIds: string[],
|
||||
excludeSprintId?: string,
|
||||
): Promise<Map<string, { sprintId: string; sprintName: string }>> {
|
||||
const out = new Map<string, { sprintId: string; sprintName: string }>()
|
||||
if (storyIds.length === 0) return out
|
||||
|
||||
const stories = await prisma.story.findMany({
|
||||
where: {
|
||||
id: { in: storyIds },
|
||||
product_id: productId,
|
||||
sprint_id: { not: null },
|
||||
sprint: { status: 'OPEN' },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
sprint_id: true,
|
||||
sprint: { select: { id: true, code: true, status: true } },
|
||||
},
|
||||
})
|
||||
|
||||
for (const story of stories) {
|
||||
if (!story.sprint) continue
|
||||
if (excludeSprintId !== undefined && story.sprint.id === excludeSprintId) continue
|
||||
out.set(story.id, {
|
||||
sprintId: story.sprint.id,
|
||||
sprintName: story.sprint.code,
|
||||
})
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue