feat: branch-per-story + worktree-defer + verify EMPTY edge-cases (#12)
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>
This commit is contained in:
parent
f87b20744b
commit
f01fab8c38
7 changed files with 248 additions and 36 deletions
|
|
@ -4,6 +4,7 @@ vi.mock('../src/prisma.js', () => ({
|
|||
prisma: {
|
||||
product: { findUnique: vi.fn() },
|
||||
task: { findUnique: vi.fn() },
|
||||
claudeJob: { findFirst: vi.fn() },
|
||||
},
|
||||
}))
|
||||
|
||||
|
|
@ -18,6 +19,7 @@ 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> }
|
||||
}
|
||||
const mockCreatePr = createPullRequest as ReturnType<typeof vi.fn>
|
||||
|
||||
|
|
@ -35,23 +37,31 @@ beforeEach(() => {
|
|||
mockPrisma.product.findUnique.mockResolvedValue({ auto_pr: true })
|
||||
mockPrisma.task.findUnique.mockResolvedValue({
|
||||
title: 'Add feature',
|
||||
story: { code: 'SCRUM-42' },
|
||||
story: { id: 'story-1', code: 'SCRUM-42', title: 'Story title' },
|
||||
})
|
||||
mockPrisma.claudeJob.findFirst.mockResolvedValue(null) // no sibling PR by default
|
||||
mockCreatePr.mockResolvedValue({ url: 'https://github.com/org/repo/pull/99' })
|
||||
})
|
||||
|
||||
describe('maybeCreateAutoPr', () => {
|
||||
it('returns PR URL when auto_pr=true and gh succeeds', async () => {
|
||||
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: Add feature',
|
||||
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)
|
||||
|
|
@ -59,14 +69,14 @@ describe('maybeCreateAutoPr', () => {
|
|||
expect(mockCreatePr).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses task title without code prefix when story has no code', async () => {
|
||||
it('uses story title without code prefix when story has no code', async () => {
|
||||
mockPrisma.task.findUnique.mockResolvedValue({
|
||||
title: 'Add feature',
|
||||
story: { code: null },
|
||||
story: { id: 'story-1', code: null, title: 'Story title' },
|
||||
})
|
||||
await maybeCreateAutoPr(BASE_OPTS)
|
||||
expect(mockCreatePr).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: 'Add feature' }),
|
||||
expect.objectContaining({ title: 'Story title' }),
|
||||
)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
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(),
|
||||
}))
|
||||
|
|
@ -12,15 +18,25 @@ vi.mock('../src/tools/wait-for-job.js', async (importOriginal) => {
|
|||
}
|
||||
})
|
||||
|
||||
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', () => {
|
||||
|
|
@ -81,4 +97,14 @@ describe('cleanupWorktreeForTerminalStatus', () => {
|
|||
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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import * as fs from 'node:fs/promises'
|
|||
vi.mock('../src/prisma.js', () => ({
|
||||
prisma: {
|
||||
$executeRaw: vi.fn(),
|
||||
claudeJob: { findFirst: vi.fn() },
|
||||
product: { findUnique: vi.fn() },
|
||||
},
|
||||
}))
|
||||
|
||||
|
|
@ -17,7 +19,11 @@ import { prisma } from '../src/prisma.js'
|
|||
import { createWorktreeForJob } from '../src/git/worktree.js'
|
||||
import { resolveRepoRoot, rollbackClaim, attachWorktreeToJob } from '../src/tools/wait-for-job.js'
|
||||
|
||||
const mockPrisma = prisma as unknown as { $executeRaw: ReturnType<typeof vi.fn> }
|
||||
const mockPrisma = prisma as unknown as {
|
||||
$executeRaw: ReturnType<typeof vi.fn>
|
||||
claudeJob: { findFirst: ReturnType<typeof vi.fn> }
|
||||
product: { findUnique: ReturnType<typeof vi.fn> }
|
||||
}
|
||||
const mockCreateWorktree = createWorktreeForJob as ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
|
|
@ -88,32 +94,51 @@ describe('attachWorktreeToJob', () => {
|
|||
Object.assign(process.env, originalEnv)
|
||||
})
|
||||
|
||||
it('returns worktree_path and branch_name on success', async () => {
|
||||
it('returns worktree_path and branch_name on success (no sibling → fresh story branch)', async () => {
|
||||
process.env['SCRUM4ME_REPO_ROOT_prod-001'] = '/repos/my-project'
|
||||
mockPrisma.claudeJob.findFirst.mockResolvedValue(null)
|
||||
mockCreateWorktree.mockResolvedValue({
|
||||
worktreePath: '/home/user/.scrum4me-agent-worktrees/job-abc12345',
|
||||
branchName: 'feat/job-abc12345',
|
||||
branchName: 'feat/story-XXXstory',
|
||||
})
|
||||
mockPrisma.$executeRaw.mockResolvedValue(0)
|
||||
|
||||
const result = await attachWorktreeToJob('prod-001', 'job-abc12345')
|
||||
const result = await attachWorktreeToJob('prod-001', 'job-abc12345', 'story-XXXstory')
|
||||
|
||||
expect(result).toEqual({
|
||||
worktree_path: '/home/user/.scrum4me-agent-worktrees/job-abc12345',
|
||||
branch_name: 'feat/job-abc12345',
|
||||
branch_name: 'feat/story-XXXstory',
|
||||
reused_branch: false,
|
||||
})
|
||||
expect(mockCreateWorktree).toHaveBeenCalledWith({
|
||||
repoRoot: '/repos/my-project',
|
||||
jobId: 'job-abc12345',
|
||||
branchName: 'feat/job-abc12345',
|
||||
branchName: 'feat/story-XXXstory',
|
||||
reuseBranch: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('reuses sibling branch when sibling job already has a branch in same story', async () => {
|
||||
process.env['SCRUM4ME_REPO_ROOT_prod-001'] = '/repos/my-project'
|
||||
mockPrisma.claudeJob.findFirst.mockResolvedValue({ branch: 'feat/story-existing' })
|
||||
mockCreateWorktree.mockResolvedValue({
|
||||
worktreePath: '/home/user/.scrum4me-agent-worktrees/job-zzz',
|
||||
branchName: 'feat/story-existing',
|
||||
})
|
||||
mockPrisma.$executeRaw.mockResolvedValue(0)
|
||||
|
||||
const result = await attachWorktreeToJob('prod-001', 'job-zzz', 'story-shared')
|
||||
|
||||
expect(result).toMatchObject({ branch_name: 'feat/story-existing', reused_branch: true })
|
||||
expect(mockCreateWorktree).toHaveBeenCalledWith(expect.objectContaining({ reuseBranch: true }))
|
||||
})
|
||||
|
||||
it('rolls back claim and returns error when no repoRoot configured', async () => {
|
||||
delete process.env['SCRUM4ME_REPO_ROOT_prod-no-root']
|
||||
mockPrisma.product.findUnique.mockResolvedValue({ repo_url: null })
|
||||
mockPrisma.$executeRaw.mockResolvedValue(0)
|
||||
|
||||
const result = await attachWorktreeToJob('prod-no-root', 'job-xyz')
|
||||
const result = await attachWorktreeToJob('prod-no-root', 'job-xyz', 'story-y')
|
||||
|
||||
expect('error' in result).toBe(true)
|
||||
expect((result as { error: string }).error).toContain('No repo root configured')
|
||||
|
|
@ -124,10 +149,11 @@ describe('attachWorktreeToJob', () => {
|
|||
|
||||
it('rolls back claim and returns error when createWorktreeForJob throws', async () => {
|
||||
process.env['SCRUM4ME_REPO_ROOT_prod-001'] = '/repos/my-project'
|
||||
mockPrisma.claudeJob.findFirst.mockResolvedValue(null)
|
||||
mockCreateWorktree.mockRejectedValue(new Error('git fetch failed'))
|
||||
mockPrisma.$executeRaw.mockResolvedValue(0)
|
||||
|
||||
const result = await attachWorktreeToJob('prod-001', 'job-fail')
|
||||
const result = await attachWorktreeToJob('prod-001', 'job-fail', 'story-z')
|
||||
|
||||
expect('error' in result).toBe(true)
|
||||
expect((result as { error: string }).error).toContain('git fetch failed')
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue