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:
parent
7b135e12dd
commit
454d96ee04
7 changed files with 359 additions and 24 deletions
|
|
@ -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') })
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
91
__tests__/wait-for-job-branch-resolution.test.ts
Normal file
91
__tests__/wait-for-job-branch-resolution.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue