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>
136 lines
4.6 KiB
TypeScript
136 lines
4.6 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
|
|
vi.mock('../src/prisma.js', () => ({
|
|
prisma: {
|
|
product: { findUnique: vi.fn() },
|
|
task: { findUnique: 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'
|
|
import { createPullRequest } from '../src/git/pr.js'
|
|
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>
|
|
findUnique: ReturnType<typeof vi.fn>
|
|
}
|
|
}
|
|
const mockCreatePr = createPullRequest as ReturnType<typeof vi.fn>
|
|
|
|
const BASE_OPTS = {
|
|
jobId: 'job-abc',
|
|
productId: 'prod-1',
|
|
taskId: 'task-1',
|
|
worktreePath: '/wt/job-abc',
|
|
branchName: 'feat/job-abc',
|
|
summary: 'Implemented the feature',
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
mockPrisma.product.findUnique.mockResolvedValue({ auto_pr: true })
|
|
mockPrisma.task.findUnique.mockResolvedValue({
|
|
title: 'Add feature',
|
|
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' })
|
|
})
|
|
|
|
describe('maybeCreateAutoPr', () => {
|
|
it('returns PR URL when auto_pr=true and gh succeeds (story-scoped title)', async () => {
|
|
const url = await maybeCreateAutoPr(BASE_OPTS)
|
|
expect(url).toBe('https://github.com/org/repo/pull/99')
|
|
expect(mockCreatePr).toHaveBeenCalledWith({
|
|
worktreePath: BASE_OPTS.worktreePath,
|
|
branchName: BASE_OPTS.branchName,
|
|
title: 'SCRUM-42: Story title',
|
|
body: expect.stringContaining(BASE_OPTS.summary),
|
|
})
|
|
})
|
|
|
|
it('reuses sibling pr_url when another job in same story already opened a PR', async () => {
|
|
mockPrisma.claudeJob.findFirst.mockResolvedValue({ pr_url: 'https://github.com/org/repo/pull/77' })
|
|
const url = await maybeCreateAutoPr(BASE_OPTS)
|
|
expect(url).toBe('https://github.com/org/repo/pull/77')
|
|
expect(mockCreatePr).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('returns null when auto_pr=false', async () => {
|
|
mockPrisma.product.findUnique.mockResolvedValue({ auto_pr: false })
|
|
const url = await maybeCreateAutoPr(BASE_OPTS)
|
|
expect(url).toBeNull()
|
|
expect(mockCreatePr).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('uses story title without code prefix when story has no code', async () => {
|
|
mockPrisma.task.findUnique.mockResolvedValue({
|
|
title: 'Add feature',
|
|
story: { id: 'story-1', code: null, title: 'Story title' },
|
|
})
|
|
await maybeCreateAutoPr(BASE_OPTS)
|
|
expect(mockCreatePr).toHaveBeenCalledWith(
|
|
expect.objectContaining({ title: 'Story title' }),
|
|
)
|
|
})
|
|
|
|
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)
|
|
expect(url).toBeNull()
|
|
})
|
|
|
|
it('returns null when product not found', async () => {
|
|
mockPrisma.product.findUnique.mockResolvedValue(null)
|
|
const url = await maybeCreateAutoPr(BASE_OPTS)
|
|
expect(url).toBeNull()
|
|
expect(mockCreatePr).not.toHaveBeenCalled()
|
|
})
|
|
})
|