scrum4me-mcp/src/git/pr.ts
Madhura68 f7f5a487ec PBI-9 + PBI-47: worktree foundation, product-worktrees, P0 fixes, PAUSED flow
Adds two interlocking PBIs:

PBI-9 — Worktree foundation + persistent product-worktrees for idea-jobs
  - src/git/worktree-paths.ts: centralised root + skip-set + lock-path helpers
  - src/git/file-lock.ts: proper-lockfile wrapper, deadlock-safe ordered acquire
  - src/git/product-worktree.ts: detached-HEAD worktree per product, .scratch/
    excluded via git rev-parse --git-path (handles linked .git file)
  - src/git/job-locks.ts: setupProductWorktrees + releaseLocksOnTerminal
  - wait-for-job.ts: idea-branch wires product-worktrees for IDEA_GRILL/MAKE_PLAN
  - update-job-status.ts + pbi-cascade.ts + stale-reset: release on all four
    server-side terminal transitions (DONE/FAILED/CANCELLED/stale)
  - cleanup-my-worktrees: skip _products/ + *.lock
  - README: worktrees section with single-host invariant + advisory-lock path

PBI-47 — Sprint-flow P0 corrections + PAUSED flow with rich pause_context
  - prisma schema: ClaudeJob.{base_sha,head_sha} + SprintRun.pause_context
  - tryClaimJob captures base_sha; prepareDoneUpdate captures head_sha
  - verify-task-against-plan diffs vs base_sha (no more origin/main fallback);
    rejects with MISSING_BASE_SHA when null — fixes per-task verify-scope P0
  - pr.ts: createPullRequest enableAutoMerge default false; new
    enableAutoMergeOnPr with --match-head-commit guard + 5-category typed
    EnableAutoMergeResult — fixes STORY auto-merge timing P0
  - src/flow/{effects,worktree-lease,pr-flow,sprint-run}.ts: pure transition
    modules + idempotent declarative effects executor
  - update-job-status: STORY auto-merge fires only on the last task of the
    story (story.status === DONE), with head_sha as merge guard; MERGE_CONFLICT
    routes to sprint-run flow which produces CREATE_CLAUDE_QUESTION +
    SET_SPRINT_RUN_STATUS effects with rich pause_context

Tests: 31 test files, 242 passing. Pure-transition tests cover STORY 3-tasks
auto-merge timing, SPRINT draft→ready, MERGE_CONFLICT pause/resume, file-lock
deadlock prevention, worktree-lease lifecycle, delete-only verify (ALIGNED),
per-job verify scope (base_sha isolation), 5-category auto-merge errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 21:09:48 +02:00

293 lines
9.8 KiB
TypeScript

import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import * as path from 'node:path'
import { getWorktreeRoot } from './worktree-paths.js'
const exec = promisify(execFile)
export async function createPullRequest(opts: {
worktreePath: string
branchName: string
title: string
body: string
/** Open as draft PR (mens moet 'm later ready-for-review zetten). Default false. */
draft?: boolean
/**
* PBI-47 (P0): default changed to false. Auto-merge is now enabled
* separately via `enableAutoMergeOnPr` only on the **last task** of a
* STORY-mode story, with a head-SHA guard to prevent racing earlier
* task merges. Callers may still pass `true` for one-off PRs that
* are immediately ready to merge; in that case we use the new typed
* helper rather than the previous fire-and-forget gh call.
*/
enableAutoMerge?: boolean
}): Promise<{ url: string } | { error: string }> {
const { worktreePath, branchName, title, body, draft = false, enableAutoMerge = false } = opts
let url: string
try {
const args = ['pr', 'create', '--title', title, '--body', body, '--head', branchName]
if (draft) args.push('--draft')
const { stdout } = await exec('gh', args, { cwd: worktreePath })
// gh prints the PR URL as the last non-empty line
const lines = stdout.trim().split('\n').filter(Boolean)
url = lines[lines.length - 1]?.trim() ?? ''
if (!url.startsWith('http')) {
return { error: `gh pr create produced unexpected output: ${stdout.slice(0, 200)}` }
}
} catch (err: unknown) {
const msg = (err as { message?: string }).message ?? String(err)
const isNotFound =
msg.includes('command not found') ||
msg.includes('is not recognized') ||
msg.includes('ENOENT')
if (isNotFound) {
return { error: 'gh CLI not found — install GitHub CLI to enable auto-PR' }
}
return { error: `gh pr create failed: ${msg.slice(0, 300)}` }
}
// Legacy opt-in: enableAutoMerge=true and not draft → fire the new typed
// helper without head-SHA guard (caller didn't supply one). Result is
// logged but not propagated — same shape as before.
if (enableAutoMerge && !draft) {
const result = await enableAutoMergeOnPr({ prUrl: url, cwd: worktreePath })
if (!result.ok) {
console.warn(
`[createPullRequest] auto-merge enable failed for ${url}: ${result.reason} ${result.stderr.slice(0, 200)}`,
)
}
}
return { url }
}
export type AutoMergeFailReason =
| 'CHECKS_FAILED'
| 'MERGE_CONFLICT'
| 'GH_AUTH_ERROR'
| 'AUTO_MERGE_NOT_ALLOWED'
| 'UNKNOWN'
export type EnableAutoMergeResult =
| { ok: true }
| { ok: false; reason: AutoMergeFailReason; stderr: string }
function classifyAutoMergeError(stderr: string): AutoMergeFailReason {
if (/conflict|not in mergeable state|dirty/i.test(stderr)) return 'MERGE_CONFLICT'
if (/checks? failed|status check|required check/i.test(stderr)) return 'CHECKS_FAILED'
if (/authentication|HTTP 401|HTTP 403|permission|gh auth/i.test(stderr)) return 'GH_AUTH_ERROR'
if (/auto-?merge.*not.*allowed|auto-?merge.*disabled/i.test(stderr)) return 'AUTO_MERGE_NOT_ALLOWED'
return 'UNKNOWN'
}
/**
* Enable auto-merge (squash) on a PR with an optional head-SHA guard.
*
* PBI-47 (P0): when `expectedHeadSha` is provided we pass `--match-head-commit`
* so GitHub only activates auto-merge if the remote head still matches the
* SHA the caller observed. This prevents racing late pushes from another
* worker triggering a merge of a different commit set.
*/
export async function enableAutoMergeOnPr(opts: {
prUrl: string
expectedHeadSha?: string
cwd?: string
}): Promise<EnableAutoMergeResult> {
try {
const args = ['pr', 'merge', '--auto', '--squash']
if (opts.expectedHeadSha) args.push('--match-head-commit', opts.expectedHeadSha)
args.push(opts.prUrl)
await exec('gh', args, opts.cwd ? { cwd: opts.cwd } : {})
return { ok: true }
} catch (err) {
const stderr =
(err as { stderr?: string }).stderr ?? (err as Error).message ?? ''
return { ok: false, reason: classifyAutoMergeError(stderr), stderr: stderr.slice(0, 500) }
}
}
// Zet een draft-PR over naar "ready for review". Gebruikt bij sprint-mode
// wanneer alle stories in de SprintRun DONE zijn — mens reviewt en mergt zelf.
export async function markPullRequestReady(opts: {
prUrl: string
cwd?: string
}): Promise<{ ok: true } | { error: string }> {
try {
await exec('gh', ['pr', 'ready', opts.prUrl], opts.cwd ? { cwd: opts.cwd } : {})
return { ok: true }
} catch (err) {
const msg = (err as { stderr?: string }).stderr ?? (err as Error).message ?? ''
// gh-CLI fout "Pull request is not in draft state" is benign wanneer de
// PR al ready was (bv. handmatig ready gezet of een tweede call).
if (/not in draft state|already in ready/i.test(msg)) return { ok: true }
return { error: `gh pr ready failed: ${msg.slice(0, 300)}` }
}
}
export type PrState = 'OPEN' | 'MERGED' | 'CLOSED'
export type PrInfo = {
state: PrState
mergeCommit: string | null
baseRefName: string
title: string
}
export async function getPullRequestState(opts: {
prUrl: string
cwd?: string
}): Promise<PrInfo | { error: string }> {
const { prUrl } = opts
try {
const { stdout } = await exec(
'gh',
['pr', 'view', prUrl, '--json', 'state,mergeCommit,baseRefName,title'],
opts.cwd ? { cwd: opts.cwd } : {},
)
const parsed = JSON.parse(stdout) as {
state: string
mergeCommit: { oid: string } | null
baseRefName: string
title: string
}
const state = parsed.state.toUpperCase() as PrState
if (state !== 'OPEN' && state !== 'MERGED' && state !== 'CLOSED') {
return { error: `unexpected PR state: ${parsed.state}` }
}
return {
state,
mergeCommit: parsed.mergeCommit?.oid ?? null,
baseRefName: parsed.baseRefName,
title: parsed.title,
}
} catch (err) {
const msg = (err as { stderr?: string }).stderr ?? (err as Error).message ?? ''
return { error: `gh pr view failed: ${msg.slice(0, 300)}` }
}
}
export async function closePullRequest(opts: {
prUrl: string
comment: string
cwd?: string
}): Promise<{ ok: true } | { error: string }> {
try {
await exec(
'gh',
['pr', 'close', opts.prUrl, '--delete-branch', '--comment', opts.comment],
opts.cwd ? { cwd: opts.cwd } : {},
)
return { ok: true }
} catch (err) {
const msg = (err as { stderr?: string }).stderr ?? (err as Error).message ?? ''
return { error: `gh pr close failed: ${msg.slice(0, 300)}` }
}
}
// Creates a revert-PR for a merged PR. Uses an isolated worktree so it
// never touches the user's main checkout. Returns the new PR URL or an
// error string. The revert PR is opened WITHOUT auto-merge — the user
// must review + merge it manually so an unintended cascade can be undone.
export async function createRevertPullRequest(opts: {
repoRoot: string
mergeSha: string
baseRef: string
originalTitle: string
originalBranch: string
jobId: string
pbiCode: string | null
}): Promise<{ url: string } | { error: string }> {
const {
repoRoot,
mergeSha,
baseRef,
originalTitle,
originalBranch,
jobId,
pbiCode,
} = opts
const worktreeDir = getWorktreeRoot()
const wtPath = path.join(worktreeDir, `revert-${jobId}`)
const revertBranch = `revert/${originalBranch}-${jobId.slice(-8)}`
const run = async (cmd: string, args: string[], cwd: string) => {
await exec(cmd, args, { cwd })
}
// Cleanup helper, best-effort
const cleanup = async () => {
try {
await exec('git', ['worktree', 'remove', '--force', wtPath], { cwd: repoRoot })
} catch {
// ignore — worktree may not exist if creation failed
}
}
try {
await run('git', ['fetch', 'origin', baseRef, mergeSha], repoRoot)
await run('git', ['worktree', 'add', '-b', revertBranch, wtPath, `origin/${baseRef}`], repoRoot)
try {
await run('git', ['revert', '-m', '1', mergeSha, '--no-edit'], wtPath)
} catch (err) {
await cleanup()
const msg = (err as { stderr?: string }).stderr ?? (err as Error).message ?? ''
if (/conflict/i.test(msg)) {
return { error: `git revert conflicts on ${mergeSha}: ${msg.slice(0, 200)}` }
}
return { error: `git revert failed: ${msg.slice(0, 200)}` }
}
await run('git', ['push', '-u', 'origin', revertBranch], wtPath)
const pbiTag = pbiCode ? `PBI ${pbiCode}` : 'PBI'
const title = `Revert: ${originalTitle}`
const body = [
`Auto-revert by Scrum4Me agent.`,
``,
`Reason: ${pbiTag} failed (cascade from job \`${jobId}\`).`,
`Reverts merge commit \`${mergeSha}\`.`,
``,
`**Review carefully before merging** — auto-merge is intentionally NOT enabled on revert PRs.`,
].join('\n')
let prUrl: string
try {
const { stdout } = await exec(
'gh',
[
'pr',
'create',
'--base',
baseRef,
'--head',
revertBranch,
'--title',
title,
'--body',
body,
],
{ cwd: wtPath },
)
const lines = stdout.trim().split('\n').filter(Boolean)
prUrl = lines[lines.length - 1]?.trim() ?? ''
if (!prUrl.startsWith('http')) {
await cleanup()
return { error: `gh pr create produced unexpected output: ${stdout.slice(0, 200)}` }
}
} catch (err) {
await cleanup()
const msg = (err as { stderr?: string }).stderr ?? (err as Error).message ?? ''
return { error: `gh pr create (revert) failed: ${msg.slice(0, 300)}` }
}
await cleanup()
return { url: prUrl }
} catch (err) {
await cleanup()
const msg = (err as { stderr?: string }).stderr ?? (err as Error).message ?? ''
return { error: `revert worktree setup failed: ${msg.slice(0, 300)}` }
}
}