From 2093a7788e7423fc567d52fe88257b19bdcc0d41 Mon Sep 17 00:00:00 2001 From: Scrum4Me Agent <30029041+madhura68@users.noreply.github.com> Date: Sun, 3 May 2026 17:18:08 +0200 Subject: [PATCH] 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 --- __tests__/actions/claude-jobs-batch.test.ts | 58 ++++++++++++++++++++- __tests__/actions/claude-jobs.test.ts | 2 + actions/claude-jobs.ts | 20 ++++++- 3 files changed, 78 insertions(+), 2 deletions(-) diff --git a/__tests__/actions/claude-jobs-batch.test.ts b/__tests__/actions/claude-jobs-batch.test.ts index fc4e5e7..da7a42a 100644 --- a/__tests__/actions/claude-jobs-batch.test.ts +++ b/__tests__/actions/claude-jobs-batch.test.ts @@ -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() + }) }) diff --git a/__tests__/actions/claude-jobs.test.ts b/__tests__/actions/claude-jobs.test.ts index 120124c..83cd3a0 100644 --- a/__tests__/actions/claude-jobs.test.ts +++ b/__tests__/actions/claude-jobs.test.ts @@ -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' }] : [], }) diff --git a/actions/claude-jobs.ts b/actions/claude-jobs.ts index d7ba1e3..b3d1085 100644 --- a/actions/claude-jobs.ts +++ b/actions/claude-jobs.ts @@ -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 }