Implementeert vier open stories uit PBI 'Veilige Claude-agent-workflow': **Branch per story (cmon11tbe001zbortx35n155c)** - `resolveBranchForJob`: zoek sibling-job in dezelfde story; reuse z'n branch (1 PR per story i.p.v. per task). - Branch-naam: `feat/story-<8-char>` voor nieuwe stories. - `createWorktreeForJob` kent nu `reuseBranch=true`: detecteert stale sibling-worktree die de branch nog vasthoudt en verwijdert die eerst. - `attachWorktreeToJob` neemt `storyId` mee. **PR-hergebruik (zelfde story)** - `maybeCreateAutoPr`: als sibling-job in story al een pr_url heeft, hergebruik die zonder nieuwe `gh pr create`-call. PR-titel komt nu van de story (was task) zodat het als 'story-PR' leest. **Worktree-cleanup uitgesteld bij actieve siblings** - `cleanupWorktreeForTerminalStatus`: count active sibling-jobs in dezelfde story; defer als > 0 (volgende sub-task gebruikt branch). **Worktree-cleanup logging (cmon0jc14001ubortjxf2a2ck)** - Warning bij ontbrekende repoRoot, met productId + jobId in message. - Warning bij removeWorktreeForJob-failure met keepBranch in message. **resolveRepoRoot fallback (cmon0jc14001ubortjxf2a2ck)** - Convention-based fallback: `~/Projects/<repo-name>` afgeleid uit `product.repo_url` als noch env-var noch config-bestand iets oplevert. - `repoNameFromUrl` helper geëxporteerd voor herbruikbaarheid. **Verify EMPTY-detection edge-case (cmon0kdq6001xbort2kgbcqmr)** - `classifyDiffAgainstPlan`: na file-paths-check ook content-lines checken; als alle +/- regels alleen headers of whitespace zijn, return EMPTY met duidelijke reasoning. Tests: 120/120 groen (3 nieuwe), tsc clean, build clean. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
110 lines
3.6 KiB
TypeScript
110 lines
3.6 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
|
|
vi.mock('../src/prisma.js', () => ({
|
|
prisma: {
|
|
claudeJob: { findUnique: vi.fn(), count: vi.fn() },
|
|
},
|
|
}))
|
|
|
|
vi.mock('../src/git/worktree.js', () => ({
|
|
removeWorktreeForJob: vi.fn(),
|
|
}))
|
|
|
|
vi.mock('../src/tools/wait-for-job.js', async (importOriginal) => {
|
|
const original = await importOriginal<typeof import('../src/tools/wait-for-job.js')>()
|
|
return {
|
|
...original,
|
|
resolveRepoRoot: vi.fn(),
|
|
}
|
|
})
|
|
|
|
import { prisma } from '../src/prisma.js'
|
|
import { removeWorktreeForJob } from '../src/git/worktree.js'
|
|
import { resolveRepoRoot } from '../src/tools/wait-for-job.js'
|
|
import { cleanupWorktreeForTerminalStatus } from '../src/tools/update-job-status.js'
|
|
|
|
const mockRemove = removeWorktreeForJob as ReturnType<typeof vi.fn>
|
|
const mockResolve = resolveRepoRoot as ReturnType<typeof vi.fn>
|
|
const mockPrisma = prisma as unknown as {
|
|
claudeJob: {
|
|
findUnique: ReturnType<typeof vi.fn>
|
|
count: ReturnType<typeof vi.fn>
|
|
}
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
// Default: job exists, no active siblings — cleanup proceeds
|
|
mockPrisma.claudeJob.findUnique.mockResolvedValue({ task: { story_id: 'story-default' } })
|
|
mockPrisma.claudeJob.count.mockResolvedValue(0)
|
|
})
|
|
|
|
describe('cleanupWorktreeForTerminalStatus', () => {
|
|
it('calls removeWorktreeForJob with keepBranch=true when done and branch set', async () => {
|
|
mockResolve.mockResolvedValue('/repos/my-project')
|
|
mockRemove.mockResolvedValue({ removed: true })
|
|
|
|
await cleanupWorktreeForTerminalStatus('prod-001', 'job-abc', 'done', 'feat/job-abc')
|
|
|
|
expect(mockRemove).toHaveBeenCalledWith({
|
|
repoRoot: '/repos/my-project',
|
|
jobId: 'job-abc',
|
|
keepBranch: true,
|
|
})
|
|
})
|
|
|
|
it('calls removeWorktreeForJob with keepBranch=false when done but no branch', async () => {
|
|
mockResolve.mockResolvedValue('/repos/my-project')
|
|
mockRemove.mockResolvedValue({ removed: true })
|
|
|
|
await cleanupWorktreeForTerminalStatus('prod-001', 'job-abc', 'done', undefined)
|
|
|
|
expect(mockRemove).toHaveBeenCalledWith({
|
|
repoRoot: '/repos/my-project',
|
|
jobId: 'job-abc',
|
|
keepBranch: false,
|
|
})
|
|
})
|
|
|
|
it('calls removeWorktreeForJob with keepBranch=false when failed', async () => {
|
|
mockResolve.mockResolvedValue('/repos/my-project')
|
|
mockRemove.mockResolvedValue({ removed: true })
|
|
|
|
await cleanupWorktreeForTerminalStatus('prod-001', 'job-abc', 'failed', 'feat/job-abc')
|
|
|
|
expect(mockRemove).toHaveBeenCalledWith({
|
|
repoRoot: '/repos/my-project',
|
|
jobId: 'job-abc',
|
|
keepBranch: false,
|
|
})
|
|
})
|
|
|
|
it('skips cleanup and does not throw when no repoRoot configured', async () => {
|
|
mockResolve.mockResolvedValue(null)
|
|
|
|
await expect(
|
|
cleanupWorktreeForTerminalStatus('prod-no-root', 'job-abc', 'done', undefined),
|
|
).resolves.toBeUndefined()
|
|
|
|
expect(mockRemove).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('does not throw when removeWorktreeForJob fails (best-effort)', async () => {
|
|
mockResolve.mockResolvedValue('/repos/my-project')
|
|
mockRemove.mockRejectedValue(new Error('git error'))
|
|
|
|
await expect(
|
|
cleanupWorktreeForTerminalStatus('prod-001', 'job-abc', 'done', 'feat/job-abc'),
|
|
).resolves.toBeUndefined()
|
|
})
|
|
|
|
it('defers cleanup when sibling jobs in same story are still active', async () => {
|
|
mockResolve.mockResolvedValue('/repos/my-project')
|
|
mockPrisma.claudeJob.findUnique.mockResolvedValue({ task: { story_id: 'story-shared' } })
|
|
mockPrisma.claudeJob.count.mockResolvedValue(2) // 2 siblings active
|
|
|
|
await cleanupWorktreeForTerminalStatus('prod-001', 'job-abc', 'done', 'feat/story-shared')
|
|
|
|
expect(mockRemove).not.toHaveBeenCalled()
|
|
})
|
|
})
|