scrum4me-mcp/__tests__/update-job-status-auto-pr.test.ts
Janpeter Visser f01fab8c38
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>
2026-05-01 17:04:54 +02:00

95 lines
3.2 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() },
},
}))
vi.mock('../src/git/pr.js', () => ({
createPullRequest: 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> }
}
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
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('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()
})
})