Cross-repo sprints (sprint-product = repo X, maar een taak heeft
task.repo_url naar repo Y) faalden op twee plekken omdat sprint-brede
beslissingen werden toegepast op per-repo git-state.
1. createWorktreeForJob (src/git/worktree.ts)
reuseBranch wordt sprint-breed bepaald in wait-for-job.ts. De eerste
job die repo Y target krijgt reuseBranch=true terwijl de branch daar
nooit is aangemaakt -> `git worktree add <path> <branch>` faalt met
"invalid reference" -> job vast, worker UNHEALTHY. Idem na een
container-recreate (clone is dan vers).
Fix: 3-weg fallback in het reuseBranch-pad:
- lokale branch bestaat -> hergebruik
- alleen op origin -> recreate lokaal vanaf origin/<branch>
- nergens -> fresh vanaf baseRef
Lost ook het container-recreate-verlies op.
2. maybeCreateAutoPr (src/tools/update-job-status.ts)
De sprint/story sibling-lookup voor pr_url-hergebruik filterde niet
op repo. Een repo-Y-job erfde de pr_url van een repo-X-sibling ->
job.pr_url wees naar de verkeerde repo en er werd nooit een PR voor
de repo-Y-branch aangemaakt (branch wel gepusht, maar PR-loos).
Fix: siblings groeperen per repo-bucket ((task.repo_url ?? null));
alleen een sibling uit dezelfde bucket levert een herbruikbare
pr_url. Geldt voor SPRINT- en STORY-mode. createPullRequest zelf was
al repo-correct (gh pr create draait in de worktree).
Tests: 3 nieuwe in worktree.test.ts (reuse-local / recreate-from-origin
/ fresh-fallback), 2 nieuwe in update-job-status-auto-pr.test.ts
(cross-repo story + sprint). update-job-status-mock omgezet naar
findMany. Alle 373 tests groen, build groen.
package-lock.json: version 0.7.0 -> 0.8.0 (was niet mee-gesynced in de
v0.8.0-bump commit 55fa133).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
179 lines
6.4 KiB
TypeScript
179 lines
6.4 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(), findMany: 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>
|
|
findMany: 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',
|
|
repo_url: null,
|
|
story: { id: 'story-1', code: 'SCRUM-42', title: 'Story title' },
|
|
})
|
|
mockPrisma.claudeJob.findMany.mockResolvedValue([]) // no sibling PRs 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.findMany.mockResolvedValue([
|
|
{ pr_url: 'https://github.com/org/repo/pull/77', task: { repo_url: null } },
|
|
])
|
|
const url = await maybeCreateAutoPr(BASE_OPTS)
|
|
expect(url).toBe('https://github.com/org/repo/pull/77')
|
|
expect(mockCreatePr).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('does NOT reuse a sibling PR from a different repo (cross-repo story)', async () => {
|
|
// Sibling targeted another repo via task.repo_url — its PR must not leak in.
|
|
mockPrisma.claudeJob.findMany.mockResolvedValue([
|
|
{
|
|
pr_url: 'https://github.com/org/other-repo/pull/12',
|
|
task: { repo_url: 'https://github.com/org/other-repo' },
|
|
},
|
|
])
|
|
const url = await maybeCreateAutoPr(BASE_OPTS)
|
|
expect(url).toBe('https://github.com/org/repo/pull/99') // fresh PR, not the sibling's
|
|
expect(mockCreatePr).toHaveBeenCalledOnce()
|
|
})
|
|
|
|
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',
|
|
repo_url: null,
|
|
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.findMany.mockResolvedValue([
|
|
{ pr_url: 'https://github.com/org/repo/pull/55', task: { repo_url: null } },
|
|
])
|
|
|
|
const url = await maybeCreateAutoPr(BASE_OPTS)
|
|
|
|
expect(url).toBe('https://github.com/org/repo/pull/55')
|
|
expect(mockCreatePr).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('SPRINT-mode: cross-repo — sibling-PR van ander repo wordt niet hergebruikt', async () => {
|
|
mockPrisma.claudeJob.findUnique.mockResolvedValue({
|
|
sprint_run_id: 'run-1',
|
|
sprint_run: { id: 'run-1', pr_strategy: 'SPRINT', sprint: { sprint_goal: 'Goal' } },
|
|
})
|
|
// Deze job target een ander repo via task.repo_url.
|
|
mockPrisma.task.findUnique.mockResolvedValue({
|
|
title: 'MCP-taak',
|
|
repo_url: 'https://github.com/org/scrum4me-mcp',
|
|
story: { id: 'story-1', code: 'SCRUM-9', title: 'Story title' },
|
|
})
|
|
// Sibling met pr_url hoort bij het product-repo (repo_url null) → andere bucket.
|
|
mockPrisma.claudeJob.findMany.mockResolvedValue([
|
|
{ pr_url: 'https://github.com/org/repo/pull/201', task: { repo_url: null } },
|
|
])
|
|
|
|
const url = await maybeCreateAutoPr(BASE_OPTS)
|
|
|
|
// Geen hergebruik van de product-repo PR → eigen draft-PR voor het mcp-repo.
|
|
expect(url).toBe('https://github.com/org/repo/pull/99')
|
|
expect(mockCreatePr).toHaveBeenCalledOnce()
|
|
})
|
|
|
|
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()
|
|
})
|
|
})
|