PBI-8 (vervolg): Sprint-aware branch + SPRINT-mode draft-PR

T-22 — sprint-aware branch-resolutie (resolveBranchForJob):
  - SPRINT-mode  → feat/sprint-<sprint_run_id-suffix> (één branch voor hele run)
  - STORY-mode   → feat/story-<story_id-suffix>      (één per story)
  - Legacy (zonder sprint_run_id): bestaand gedrag
  Sibling-detection herbruikt branch wanneer een eerdere job in dezelfde
  scope al de branch heeft.

T-24 — SPRINT-mode draft-PR + ready-bij-DONE:
  - createPullRequest accepteert nu draft + enableAutoMerge flags
  - Nieuwe markPullRequestReady-helper voor draft → ready transitie
  - maybeCreateAutoPr in SPRINT-mode: opent één draft-PR per SprintRun met
    sprint_goal als titel; geen auto-merge; sibling-tasks hergebruiken de
    PR
  - update-job-status detecteert sprint-DONE via PropagationResult en zet
    de draft-PR via markPullRequestReady ready-for-review (mens reviewt en
    mergt zelf)

T-23 — STORY-mode dekking: bestaande createPullRequest + auto-merge gedrag
ongewijzigd. Tests uitgebreid met sprint-aware mocks; 6 nieuwe
branch-resolution tests + 2 sprint-mode auto-pr tests + 4 markPullRequest
Ready/draft-PR tests.

Tests: 195/195 groen (180 → 195; 15 nieuwe scenario's voor sprint-aware
branch + SPRINT-mode draft-PR + markPullRequestReady).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Madhura68 2026-05-06 17:15:04 +02:00
parent 7b135e12dd
commit 454d96ee04
7 changed files with 359 additions and 24 deletions

View file

@ -12,7 +12,7 @@ vi.mock('node:util', () => ({
),
}))
import { createPullRequest } from '../../src/git/pr.js'
import { createPullRequest, markPullRequestReady } from '../../src/git/pr.js'
beforeEach(() => {
vi.clearAllMocks()
@ -66,4 +66,80 @@ describe('createPullRequest', () => {
expect(result).toMatchObject({ error: expect.stringContaining('gh pr create failed') })
})
it('passes --draft when draft=true en slaat auto-merge over', async () => {
const calls: string[][] = []
mockExecFile.mockImplementation(
(
_cmd: string,
args: string[],
_opts: unknown,
cb: (err: null, res: { stdout: string; stderr: string }) => void,
) => {
calls.push(args)
cb(null, {
stdout: 'Creating draft pull request...\nhttps://github.com/org/repo/pull/100\n',
stderr: '',
})
},
)
const result = await createPullRequest({
worktreePath: '/wt/sprint-1',
branchName: 'feat/sprint-12345678',
title: 'Sprint: Cascade-flow live',
body: 'Sprint draft',
draft: true,
enableAutoMerge: false,
})
expect(result).toEqual({ url: 'https://github.com/org/repo/pull/100' })
expect(calls.some((a) => a.includes('--draft'))).toBe(true)
// gh pr merge --auto mag NIET gestart zijn voor draft + auto-merge=false
expect(calls.some((a) => a[0] === 'pr' && a[1] === 'merge')).toBe(false)
})
})
describe('markPullRequestReady', () => {
it('roept gh pr ready aan met de PR-URL', async () => {
const calls: string[][] = []
mockExecFile.mockImplementation(
(
_cmd: string,
args: string[],
_opts: unknown,
cb: (err: null, res: { stdout: string; stderr: string }) => void,
) => {
calls.push(args)
cb(null, { stdout: '', stderr: '' })
},
)
const result = await markPullRequestReady({ prUrl: 'https://github.com/org/repo/pull/100' })
expect(result).toEqual({ ok: true })
expect(calls[0]).toEqual(['pr', 'ready', 'https://github.com/org/repo/pull/100'])
})
it('behandelt "already ready" als success', async () => {
mockExecFile.mockImplementation(
(_cmd: string, _args: string[], _opts: unknown, cb: (err: Error) => void) =>
cb(Object.assign(new Error(''), { stderr: 'Pull request is not in draft state' })),
)
const result = await markPullRequestReady({ prUrl: 'https://github.com/org/repo/pull/100' })
expect(result).toEqual({ ok: true })
})
it('retourneert error op onverwachte gh-fout', async () => {
mockExecFile.mockImplementation(
(_cmd: string, _args: string[], _opts: unknown, cb: (err: Error) => void) =>
cb(new Error('rate limit exceeded')),
)
const result = await markPullRequestReady({ prUrl: 'https://github.com/org/repo/pull/100' })
expect(result).toMatchObject({ error: expect.stringContaining('gh pr ready failed') })
})
})

View file

@ -4,12 +4,13 @@ vi.mock('../src/prisma.js', () => ({
prisma: {
product: { findUnique: vi.fn() },
task: { findUnique: vi.fn() },
claudeJob: { findFirst: vi.fn() },
claudeJob: { findFirst: vi.fn(), findUnique: vi.fn() },
},
}))
vi.mock('../src/git/pr.js', () => ({
createPullRequest: vi.fn(),
markPullRequestReady: vi.fn(),
}))
import { prisma } from '../src/prisma.js'
@ -19,7 +20,10 @@ import { maybeCreateAutoPr } from '../src/tools/update-job-status.js'
const mockPrisma = prisma as unknown as {
product: { findUnique: ReturnType<typeof vi.fn> }
task: { findUnique: ReturnType<typeof vi.fn> }
claudeJob: { findFirst: ReturnType<typeof vi.fn> }
claudeJob: {
findFirst: ReturnType<typeof vi.fn>
findUnique: ReturnType<typeof vi.fn>
}
}
const mockCreatePr = createPullRequest as ReturnType<typeof vi.fn>
@ -40,6 +44,8 @@ beforeEach(() => {
story: { id: 'story-1', code: 'SCRUM-42', title: 'Story title' },
})
mockPrisma.claudeJob.findFirst.mockResolvedValue(null) // no sibling PR by default
// Default: legacy job zonder sprint_run (STORY-mode pad).
mockPrisma.claudeJob.findUnique.mockResolvedValue({ sprint_run_id: null, sprint_run: null })
mockCreatePr.mockResolvedValue({ url: 'https://github.com/org/repo/pull/99' })
})
@ -80,6 +86,41 @@ describe('maybeCreateAutoPr', () => {
)
})
it('SPRINT-mode: maakt een draft-PR aan met sprint-titel, geen auto-merge', async () => {
mockPrisma.claudeJob.findUnique.mockResolvedValue({
sprint_run_id: 'run-1',
sprint_run: {
id: 'run-1',
pr_strategy: 'SPRINT',
sprint: { sprint_goal: 'Cascade-flow live' },
},
})
const url = await maybeCreateAutoPr(BASE_OPTS)
expect(url).toBe('https://github.com/org/repo/pull/99')
expect(mockCreatePr).toHaveBeenCalledWith(
expect.objectContaining({
title: 'Sprint: Cascade-flow live',
draft: true,
enableAutoMerge: false,
}),
)
})
it('SPRINT-mode: hergebruikt sibling-PR binnen dezelfde SprintRun', async () => {
mockPrisma.claudeJob.findUnique.mockResolvedValue({
sprint_run_id: 'run-1',
sprint_run: { id: 'run-1', pr_strategy: 'SPRINT', sprint: { sprint_goal: 'Goal' } },
})
mockPrisma.claudeJob.findFirst.mockResolvedValue({ pr_url: 'https://github.com/org/repo/pull/55' })
const url = await maybeCreateAutoPr(BASE_OPTS)
expect(url).toBe('https://github.com/org/repo/pull/55')
expect(mockCreatePr).not.toHaveBeenCalled()
})
it('returns null and does not throw when gh fails', async () => {
mockCreatePr.mockResolvedValue({ error: 'gh CLI not found' })
const url = await maybeCreateAutoPr(BASE_OPTS)

View file

@ -0,0 +1,91 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('../src/prisma.js', () => ({
prisma: {
claudeJob: {
findUnique: vi.fn(),
findFirst: vi.fn(),
},
},
}))
import { prisma } from '../src/prisma.js'
import { resolveBranchForJob } from '../src/tools/wait-for-job.js'
const mockPrisma = prisma as unknown as {
claudeJob: {
findUnique: ReturnType<typeof vi.fn>
findFirst: ReturnType<typeof vi.fn>
}
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('resolveBranchForJob — sprint-aware', () => {
it('SPRINT-mode: kiest feat/sprint-<id-suffix> en marks reused=false bij eerste task', async () => {
mockPrisma.claudeJob.findUnique.mockResolvedValue({
sprint_run_id: 'run-cuid-12345678',
sprint_run: { id: 'run-cuid-12345678', pr_strategy: 'SPRINT' },
})
mockPrisma.claudeJob.findFirst.mockResolvedValue(null)
const result = await resolveBranchForJob('job-1', 'story-anything')
expect(result.branchName).toBe('feat/sprint-12345678')
expect(result.reused).toBe(false)
})
it('SPRINT-mode: marks reused=true wanneer sibling al de branch gebruikt', async () => {
mockPrisma.claudeJob.findUnique.mockResolvedValue({
sprint_run_id: 'run-cuid-12345678',
sprint_run: { id: 'run-cuid-12345678', pr_strategy: 'SPRINT' },
})
mockPrisma.claudeJob.findFirst.mockResolvedValue({ branch: 'feat/sprint-12345678' })
const result = await resolveBranchForJob('job-2', 'story-anything')
expect(result.branchName).toBe('feat/sprint-12345678')
expect(result.reused).toBe(true)
})
it('STORY-mode (sprint-flow): valt terug op story-branch via legacy-pad', async () => {
mockPrisma.claudeJob.findUnique.mockResolvedValue({
sprint_run_id: 'run-cuid-12345678',
sprint_run: { id: 'run-cuid-12345678', pr_strategy: 'STORY' },
})
mockPrisma.claudeJob.findFirst.mockResolvedValue(null)
const result = await resolveBranchForJob('job-1', 'story-cuid-87654321')
expect(result.branchName).toBe('feat/story-87654321')
expect(result.reused).toBe(false)
})
it('Legacy (geen sprint_run): bestaand gedrag — feat/story-<id-suffix>', async () => {
mockPrisma.claudeJob.findUnique.mockResolvedValue({
sprint_run_id: null,
sprint_run: null,
})
mockPrisma.claudeJob.findFirst.mockResolvedValue(null)
const result = await resolveBranchForJob('job-1', 'story-cuid-87654321')
expect(result.branchName).toBe('feat/story-87654321')
expect(result.reused).toBe(false)
})
it('Legacy: hergebruik branch wanneer sibling-job in dezelfde story al een branch heeft', async () => {
mockPrisma.claudeJob.findUnique.mockResolvedValue({
sprint_run_id: null,
sprint_run: null,
})
mockPrisma.claudeJob.findFirst.mockResolvedValue({ branch: 'feat/story-87654321' })
const result = await resolveBranchForJob('job-2', 'story-cuid-87654321')
expect(result.branchName).toBe('feat/story-87654321')
expect(result.reused).toBe(true)
})
})

View file

@ -6,7 +6,7 @@ import * as fs from 'node:fs/promises'
vi.mock('../src/prisma.js', () => ({
prisma: {
$executeRaw: vi.fn(),
claudeJob: { findFirst: vi.fn() },
claudeJob: { findFirst: vi.fn(), findUnique: vi.fn() },
product: { findUnique: vi.fn() },
},
}))
@ -21,13 +21,15 @@ import { resolveRepoRoot, rollbackClaim, attachWorktreeToJob } from '../src/tool
const mockPrisma = prisma as unknown as {
$executeRaw: ReturnType<typeof vi.fn>
claudeJob: { findFirst: ReturnType<typeof vi.fn> }
claudeJob: { findFirst: ReturnType<typeof vi.fn>; findUnique: ReturnType<typeof vi.fn> }
product: { findUnique: ReturnType<typeof vi.fn> }
}
const mockCreateWorktree = createWorktreeForJob as ReturnType<typeof vi.fn>
beforeEach(() => {
vi.clearAllMocks()
// Default: legacy job zonder sprint_run (oude flow).
mockPrisma.claudeJob.findUnique.mockResolvedValue({ sprint_run_id: null, sprint_run: null })
})
describe('resolveRepoRoot', () => {