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:
Janpeter Visser 2026-05-01 17:04:54 +02:00 committed by GitHub
parent f87b20744b
commit f01fab8c38
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 248 additions and 36 deletions

View file

@ -12,6 +12,13 @@ import { requireWriteAccess } from '../auth.js'
import { toolJson, toolError, withToolErrors } from '../errors.js'
import { createWorktreeForJob } from '../git/worktree.js'
/** Parse `https://github.com/<owner>/<name>(.git)?` → `<name>`. */
export function repoNameFromUrl(repoUrl: string | null | undefined): string | null {
if (!repoUrl) return null
const m = repoUrl.match(/[/:]([^/]+?)(?:\.git)?\/?$/)
return m ? m[1] : null
}
export async function resolveRepoRoot(productId: string): Promise<string | null> {
const envKey = `SCRUM4ME_REPO_ROOT_${productId}`
if (process.env[envKey]) return process.env[envKey]!
@ -20,7 +27,24 @@ export async function resolveRepoRoot(productId: string): Promise<string | null>
try {
const raw = await fs.readFile(configPath, 'utf-8')
const config = JSON.parse(raw) as { repoRoots?: Record<string, string> }
return config.repoRoots?.[productId] ?? null
if (config.repoRoots?.[productId]) return config.repoRoots[productId]
} catch {
// ignore — fall through to convention-based fallback
}
// Convention-based fallback: ~/Projects/<repo-name> with .git/ inside.
// Lets the agent work without explicit env-config when checkouts follow
// the standard ~/Projects/<name> layout.
try {
const product = await prisma.product.findUnique({
where: { id: productId },
select: { repo_url: true },
})
const name = repoNameFromUrl(product?.repo_url)
if (!name) return null
const candidate = path.join(os.homedir(), 'Projects', name)
await fs.access(path.join(candidate, '.git'))
return candidate
} catch {
return null
}
@ -34,10 +58,39 @@ export async function rollbackClaim(jobId: string): Promise<void> {
`
}
/**
* Resolve the branch name for a newly-claimed job.
*
* Branch-per-story: if a sibling job in the same story already has a branch
* (assigned during its own claim), reuse it so all sub-tasks in the story
* land in one PR. Otherwise generate a fresh `feat/story-<8-char>` name.
*
* Returns also `siblingHasActiveWorktree` so the caller can decide to remove
* a stale sibling worktree before creating a new one (git refuses to check
* out the same branch in two worktrees).
*/
export async function resolveBranchForJob(
jobId: string,
storyId: string,
): Promise<{ branchName: string; reused: boolean }> {
const sibling = await prisma.claudeJob.findFirst({
where: {
task: { story_id: storyId },
branch: { not: null },
id: { not: jobId },
},
orderBy: { created_at: 'asc' },
select: { branch: true },
})
if (sibling?.branch) return { branchName: sibling.branch, reused: true }
return { branchName: `feat/story-${storyId.slice(-8)}`, reused: false }
}
export async function attachWorktreeToJob(
productId: string,
jobId: string,
): Promise<{ worktree_path: string; branch_name: string } | { error: string }> {
storyId: string,
): Promise<{ worktree_path: string; branch_name: string; reused_branch: boolean } | { error: string }> {
const repoRoot = await resolveRepoRoot(productId)
if (!repoRoot) {
await rollbackClaim(jobId)
@ -48,14 +101,15 @@ export async function attachWorktreeToJob(
}
}
const branchName = `feat/job-${jobId.slice(-8)}`
const { branchName, reused } = await resolveBranchForJob(jobId, storyId)
try {
const { worktreePath, branchName: actualBranch } = await createWorktreeForJob({
repoRoot,
jobId,
branchName,
reuseBranch: reused,
})
return { worktree_path: worktreePath, branch_name: actualBranch }
return { worktree_path: worktreePath, branch_name: actualBranch, reused_branch: reused }
} catch (err) {
await rollbackClaim(jobId)
return { error: `Worktree creation failed: ${(err as Error).message}` }
@ -280,7 +334,7 @@ export function registerWaitForJobTool(server: McpServer) {
if (jobId) {
const ctx = await getFullJobContext(jobId)
if (!ctx) return toolError('Job claimed but context fetch failed')
const wt = await attachWorktreeToJob(ctx.product.id, jobId)
const wt = await attachWorktreeToJob(ctx.product.id, jobId, ctx.story.id)
if ('error' in wt) return toolError(wt.error)
return toolJson({ ...ctx, worktree_path: wt.worktree_path, branch_name: wt.branch_name })
}
@ -318,7 +372,7 @@ export function registerWaitForJobTool(server: McpServer) {
if (jobId) {
const ctx = await getFullJobContext(jobId)
if (!ctx) return toolError('Job claimed but context fetch failed')
const wt = await attachWorktreeToJob(ctx.product.id, jobId)
const wt = await attachWorktreeToJob(ctx.product.id, jobId, ctx.story.id)
if ('error' in wt) return toolError(wt.error)
return toolJson({ ...ctx, worktree_path: wt.worktree_path, branch_name: wt.branch_name })
}