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:
parent
834a71e47f
commit
2093a7788e
3 changed files with 78 additions and 2 deletions
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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' }] : [],
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue