T-22 — sprint-aware branch-resolutie (resolveBranchForJob):
- SPRINT-mode → feat/sprint-<sprint_run_id-suffix> (één branch voor hele run)
- STORY-mode → feat/story-<story_id-suffix> (één per story)
- Legacy (zonder sprint_run_id): bestaand gedrag
Sibling-detection herbruikt branch wanneer een eerdere job in dezelfde
scope al de branch heeft.
T-24 — SPRINT-mode draft-PR + ready-bij-DONE:
- createPullRequest accepteert nu draft + enableAutoMerge flags
- Nieuwe markPullRequestReady-helper voor draft → ready transitie
- maybeCreateAutoPr in SPRINT-mode: opent één draft-PR per SprintRun met
sprint_goal als titel; geen auto-merge; sibling-tasks hergebruiken de
PR
- update-job-status detecteert sprint-DONE via PropagationResult en zet
de draft-PR via markPullRequestReady ready-for-review (mens reviewt en
mergt zelf)
T-23 — STORY-mode dekking: bestaande createPullRequest + auto-merge gedrag
ongewijzigd. Tests uitgebreid met sprint-aware mocks; 6 nieuwe
branch-resolution tests + 2 sprint-mode auto-pr tests + 4 markPullRequest
Ready/draft-PR tests.
Tests: 195/195 groen (180 → 195; 15 nieuwe scenario's voor sprint-aware
branch + SPRINT-mode draft-PR + markPullRequestReady).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
249 lines
8.2 KiB
TypeScript
249 lines
8.2 KiB
TypeScript
import { execFile } from 'node:child_process'
|
|
import { promisify } from 'node:util'
|
|
import * as path from 'node:path'
|
|
import * as os from 'node:os'
|
|
|
|
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
|
|
/** Schakel auto-merge (squash) in. Default true. Voor sprint-mode: false. */
|
|
enableAutoMerge?: boolean
|
|
}): Promise<{ url: string } | { error: string }> {
|
|
const { worktreePath, branchName, title, body, draft = false, enableAutoMerge = true } = 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)}` }
|
|
}
|
|
|
|
// Best-effort: enable auto-merge (squash) on the freshly created PR. If the
|
|
// repo doesn't have "Allow auto-merge" turned on, or the token lacks scope,
|
|
// gh exits non-zero and we just log. The PR is still valid; auto-merge can
|
|
// be turned on manually. We do NOT fail the whole createPullRequest call —
|
|
// the URL was successfully obtained which is the contract this returns.
|
|
// Bij draft + sprint-flow slaan we dit over: de PR moet eerst handmatig of
|
|
// via markPullRequestReady ready-for-review worden gezet.
|
|
if (enableAutoMerge && !draft) {
|
|
try {
|
|
await exec('gh', ['pr', 'merge', '--auto', '--squash', url], { cwd: worktreePath })
|
|
} catch (err) {
|
|
const stderr =
|
|
(err as { stderr?: string }).stderr ?? (err as Error).message ?? ''
|
|
console.warn(
|
|
`[createPullRequest] auto-merge enable failed for ${url}: ${stderr.slice(0, 200)}`,
|
|
)
|
|
}
|
|
}
|
|
|
|
return { url }
|
|
}
|
|
|
|
// 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 =
|
|
process.env.SCRUM4ME_AGENT_WORKTREE_DIR ?? path.join(os.homedir(), '.scrum4me-agent-worktrees')
|
|
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)}` }
|
|
}
|
|
}
|