feat: PBI fail-cascade — cancel siblings + undo commits
Wanneer een TASK_IMPLEMENTATION-job FAILED wordt, cancelt cancelPbiOnFailure alle queued/claimed/running siblings binnen dezelfde PBI (over alle stories heen) en draait gepushte commits ongedaan: - Open PR → gh pr close --delete-branch (PR-close + remote-branch- delete in één). - Gemergde PR → revert-PR via git revert -m 1 <mergeSha> in een korte worktree, gepusht naar revert/<orig>-<jobid>, gh pr create zonder auto-merge (mens reviewed). - Branch zonder PR → best-effort git push origin --delete. Race-protectie: update_job_status weigert nu een statuswijziging op een job die al CANCELLED is met een specifieke JOB_CANCELLED-error, zodat een parallelle worker zijn lokale werk weggooit ipv een DONE te forceren. Idempotent — een tweede cascade voor dezelfde PBI is een no-op. Non-blocking — alle fouten worden warnings in de trace op de oorspronkelijke failed job zijn error-veld; cascade throwt nooit naar de caller. Niet in scope: per-product opt-out, sprint-niveau cascade, idea-job cascade. 11 nieuwe vitest-cases dekken DB-cascade, branch-grouping, open/ merged/no-PR paden, repo-root-mismatch en de never-throws-garantie. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
066a9acc48
commit
70e58f8b28
6 changed files with 747 additions and 0 deletions
170
src/git/pr.ts
170
src/git/pr.ts
|
|
@ -1,5 +1,7 @@
|
|||
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)
|
||||
|
||||
|
|
@ -53,3 +55,171 @@ export async function createPullRequest(opts: {
|
|||
|
||||
return { url }
|
||||
}
|
||||
|
||||
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)}` }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,3 +51,27 @@ export async function pushBranchForJob(opts: {
|
|||
return { pushed: false, reason: 'unknown', stderr }
|
||||
}
|
||||
}
|
||||
|
||||
export type DeleteRemoteResult =
|
||||
| { deleted: true }
|
||||
| { deleted: false; reason: 'not-found' | 'no-credentials' | 'unknown'; stderr: string }
|
||||
|
||||
export async function deleteRemoteBranch(opts: {
|
||||
repoRoot: string
|
||||
branch: string
|
||||
}): Promise<DeleteRemoteResult> {
|
||||
const { repoRoot, branch } = opts
|
||||
try {
|
||||
await exec('git', ['push', 'origin', '--delete', branch], { cwd: repoRoot })
|
||||
return { deleted: true }
|
||||
} catch (err) {
|
||||
const stderr = (err as { stderr?: string }).stderr ?? (err as Error).message ?? ''
|
||||
if (/remote ref does not exist|unable to delete .* remote ref does not exist/i.test(stderr)) {
|
||||
return { deleted: false, reason: 'not-found', stderr }
|
||||
}
|
||||
if (/Authentication failed|could not read Username/i.test(stderr)) {
|
||||
return { deleted: false, reason: 'no-credentials', stderr }
|
||||
}
|
||||
return { deleted: false, reason: 'unknown', stderr }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue