feat(ST-2): batch-enqueue gate blokkeert nieuwe PBI bij open PR

Voegt een PBI-gate toe aan enqueueClaudeJobsBatchAction: als er een PBI
met een open PR (pr_url gezet, pr_merged_at null) bestaat, worden taken
van een andere PBI geweigerd met een foutmelding die de PR-URL bevat.
Taken van dezelfde PBI als de open PR mogen wel worden ge-enqueued.

Tests: geen open PR, zelfde PBI, andere PBI geblokkeerd, pr_merged_at
gezet onblokkeert de gate.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scrum4Me Agent 2026-05-03 17:18:08 +02:00
parent 834a71e47f
commit 2093a7788e
3 changed files with 78 additions and 2 deletions

View file

@ -9,6 +9,7 @@ const {
mockGetSession,
mockFindFirstProduct,
mockFindFirstSprint,
mockFindFirstPbi,
mockFindManyTask,
mockTransaction,
mockExecuteRaw,
@ -16,6 +17,7 @@ const {
mockGetSession: vi.fn(),
mockFindFirstProduct: vi.fn(),
mockFindFirstSprint: vi.fn(),
mockFindFirstPbi: vi.fn(),
mockFindManyTask: vi.fn(),
mockTransaction: vi.fn(),
mockExecuteRaw: vi.fn().mockResolvedValue(undefined),
@ -28,6 +30,7 @@ vi.mock('@/lib/prisma', () => ({
task: { findMany: mockFindManyTask },
product: { findFirst: mockFindFirstProduct },
sprint: { findFirst: mockFindFirstSprint },
pbi: { findFirst: mockFindFirstPbi },
claudeJob: { create: vi.fn() },
$executeRaw: mockExecuteRaw,
$transaction: mockTransaction,
@ -66,8 +69,9 @@ const makePbi2Task = (id: string, status = 'TO_DO', pbiStatus = 'READY') => ({
},
})
const makeBatchTask = (id: string, hasActiveJob = false) => ({
const makeBatchTask = (id: string, hasActiveJob = false, pbiId = 'pbi-1') => ({
id,
story: { pbi_id: pbiId },
claude_jobs: hasActiveJob ? [{ id: 'job-active' }] : [],
})
@ -85,6 +89,7 @@ beforeEach(() => {
mockGetSession.mockResolvedValue(SESSION_USER)
mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID })
mockFindFirstSprint.mockResolvedValue({ id: SPRINT_ID })
mockFindFirstPbi.mockResolvedValue(null) // geen open PR by default
})
// =============================================================
@ -229,4 +234,55 @@ describe('enqueueClaudeJobsBatchAction — 2 PBI scenario', () => {
expect(result).toMatchObject({ error: expect.stringContaining('demo') })
expect(mockTransaction).not.toHaveBeenCalled()
})
it('geen open PR → enqueue OK (gate niet actief)', async () => {
mockFindFirstPbi.mockResolvedValue(null)
mockFindManyTask.mockResolvedValue([makeBatchTask('pbi1-t1'), makeBatchTask('pbi2-t1', false, 'pbi-2')])
mockTransaction.mockResolvedValue([
{ id: 'job-a', task_id: 'pbi1-t1' },
{ id: 'job-b', task_id: 'pbi2-t1' },
])
const result = await enqueueClaudeJobsBatchAction(PRODUCT_ID, ['pbi1-t1', 'pbi2-t1'])
expect(result).toEqual({ success: true, count: 2 })
expect(mockTransaction).toHaveBeenCalled()
})
it('open PR op PBI-A, input bevat alleen PBI-A taken → OK', async () => {
mockFindFirstPbi.mockResolvedValue({ id: 'pbi-1', pr_url: 'https://github.com/org/repo/pull/42' })
mockFindManyTask.mockResolvedValue([makeBatchTask('pbi1-t1', false, 'pbi-1'), makeBatchTask('pbi1-t2', false, 'pbi-1')])
mockTransaction.mockResolvedValue([
{ id: 'job-a', task_id: 'pbi1-t1' },
{ id: 'job-b', task_id: 'pbi1-t2' },
])
const result = await enqueueClaudeJobsBatchAction(PRODUCT_ID, ['pbi1-t1', 'pbi1-t2'])
expect(result).toEqual({ success: true, count: 2 })
expect(mockTransaction).toHaveBeenCalled()
})
it('open PR op PBI-A, input bevat PBI-B taken → error met PR-URL', async () => {
const PR_URL = 'https://github.com/org/repo/pull/42'
mockFindFirstPbi.mockResolvedValue({ id: 'pbi-1', pr_url: PR_URL })
mockFindManyTask.mockResolvedValue([makeBatchTask('pbi2-t1', false, 'pbi-2')])
const result = await enqueueClaudeJobsBatchAction(PRODUCT_ID, ['pbi2-t1'])
expect(result).toMatchObject({ error: expect.stringContaining(PR_URL), open_pr_url: PR_URL, open_pbi_id: 'pbi-1' })
expect(mockTransaction).not.toHaveBeenCalled()
})
it('PBI-A pr_merged_at gezet → gate retourneert null, PBI-B mag', async () => {
// pr_merged_at != null → findFirst returnt null (where-clause filtert het uit)
mockFindFirstPbi.mockResolvedValue(null)
mockFindManyTask.mockResolvedValue([makeBatchTask('pbi2-t1', false, 'pbi-2')])
mockTransaction.mockResolvedValue([{ id: 'job-a', task_id: 'pbi2-t1' }])
const result = await enqueueClaudeJobsBatchAction(PRODUCT_ID, ['pbi2-t1'])
expect(result).toEqual({ success: true, count: 1 })
expect(mockTransaction).toHaveBeenCalled()
})
})

View file

@ -35,6 +35,7 @@ vi.mock('@/lib/prisma', () => ({
task: { findFirst: mockFindFirstTask, findMany: mockFindManyTask },
product: { findFirst: mockFindFirstProduct },
sprint: { findFirst: mockFindFirstSprint },
pbi: { findFirst: vi.fn().mockResolvedValue(null) },
claudeJob: {
findFirst: mockFindFirstJob,
create: mockCreateJob,
@ -298,6 +299,7 @@ describe('previewEnqueueAllAction', () => {
const makeBatchTask = (id: string, hasActiveJob = false) => ({
id,
story: { pbi_id: 'pbi-1' },
claude_jobs: hasActiveJob ? [{ id: 'job-active' }] : [],
})

View file

@ -12,7 +12,7 @@ type EnqueueResult =
type EnqueueAllResult =
| { success: true; count: number }
| { error: string }
| { error: string; open_pr_url?: string; open_pbi_id?: string }
type CancelResult = { success: true } | { error: string }
@ -249,6 +249,7 @@ export async function enqueueClaudeJobsBatchAction(
},
select: {
id: true,
story: { select: { pbi_id: true } },
claude_jobs: {
where: { status: { in: ACTIVE_JOB_STATUSES } },
select: { id: true },
@ -260,6 +261,23 @@ export async function enqueueClaudeJobsBatchAction(
return { error: 'Een of meer taken zijn niet toegankelijk voor deze gebruiker' }
}
// Gate: blokkeer taken van een nieuwe PBI zolang een andere PBI een open PR heeft
const openPrPbi = await prisma.pbi.findFirst({
where: { product_id: productId, pr_url: { not: null }, pr_merged_at: null },
select: { id: true, pr_url: true },
})
if (openPrPbi) {
const hasTaskFromOtherPbi = authorizedTasks.some(t => t.story.pbi_id !== openPrPbi.id)
if (hasTaskFromOtherPbi) {
return {
error: `Vorige PBI heeft een open PR. Merge eerst PR ${openPrPbi.pr_url} voordat je een nieuwe PBI start.`,
open_pr_url: openPrPbi.pr_url!,
open_pbi_id: openPrPbi.id,
}
}
}
const queueable = authorizedTasks.filter(t => t.claude_jobs.length === 0)
if (queueable.length === 0) return { success: true, count: 0 }