/** * GitFlow Provider — GitHub / Azure DevOps PR abstraction. * Self-contained: only uses node built-ins. */ import { execFile } from 'child_process'; import type { GitProvider, AzureDevOpsInfo, PRInfo, PROptions } from './types.js'; export function detectProvider(remoteUrl: string): GitProvider { if (!remoteUrl) return 'unknown'; if (remoteUrl.includes('dev.azure.com') || remoteUrl.includes('visualstudio.com')) return 'azuredevops'; if (remoteUrl.includes('github.com')) return 'github'; return 'unknown'; } export function parseAzureDevOpsUrl(url: string): AzureDevOpsInfo | null { const httpsMatch = url.match(/dev\.azure\.com\/([^/]+)\/([^/]+)\/_git\/([^/]+)/); if (httpsMatch) return { org: httpsMatch[1], project: httpsMatch[2], repo: httpsMatch[3] }; const sshMatch = url.match(/ssh\.dev\.azure\.com:v3\/([^/]+)\/([^/]+)\/([^/]+)/); if (sshMatch) return { org: sshMatch[1], project: sshMatch[2], repo: sshMatch[3] }; return null; } const IS_WIN = process.platform === 'win32'; // On Windows, `az`/`gh` are `.cmd` shims that execFile cannot launch without a // shell. Quote each argument so cmd.exe receives it intact (spaces, parens, …). function quoteWinArg(arg: string): string { if (arg === '') return '""'; return /[\s"&|<>()^%!,;=]/.test(arg) ? '"' + arg.replace(/"/g, '""') + '"' : arg; } async function isToolAvailable(tool: string): Promise { return new Promise((resolve) => { if (IS_WIN) { execFile(`${tool} --version`, [], { encoding: 'utf-8', timeout: 20000, shell: true }, (error) => resolve(!error)); } else { execFile(tool, ['--version'], { encoding: 'utf-8', timeout: 20000 }, (error) => resolve(!error)); } }); } async function execTool(command: string, args: string[], cwd?: string): Promise<{ stdout: string; stderr: string; exitCode: number }> { return new Promise((resolve) => { const opts = { cwd, encoding: 'utf-8' as const, timeout: 60_000, maxBuffer: 10 * 1024 * 1024 }; if (IS_WIN) { const cmdline = [command, ...args.map(quoteWinArg)].join(' '); execFile(cmdline, [], { ...opts, shell: true }, (error, stdout, stderr) => { resolve({ stdout: (stdout || '').trim(), stderr: (stderr || '').trim(), exitCode: error ? 1 : 0 }); }); } else { execFile(command, args, opts, (error, stdout, stderr) => { resolve({ stdout: (stdout || '').trim(), stderr: (stderr || '').trim(), exitCode: error ? 1 : 0 }); }); } }); } // `az`/`gh` report failures on stderr while stdout stays empty — an error message read // from stdout alone comes back as "" and the caller gets an unusable empty `error`. function toolError(result: { stdout: string; stderr: string }): string { return result.stderr || result.stdout; } /** Azure DevOps hard limit — a longer description makes `az repos pr create` fail outright. */ export const AZURE_PR_DESCRIPTION_MAX = 4000; /** * Cap description lines so their newline-joined total (what Azure DevOps counts) stays * within AZURE_PR_DESCRIPTION_MAX. The line that crosses the cap is cut and marked with * an ellipsis; later lines are dropped. Pure — exported for tests. */ export function capAzureDescriptionLines(lines: string[]): string[] { const out: string[] = []; let total = 0; for (const line of lines) { const sep = out.length > 0 ? 1 : 0; // the joining newline if (total + sep + line.length <= AZURE_PR_DESCRIPTION_MAX) { out.push(line); total += sep + line.length; continue; } const room = AZURE_PR_DESCRIPTION_MAX - total - sep - 1; // -1 for the ellipsis if (room > 0) out.push(line.slice(0, room) + '…'); break; } return out; } /** * Build the argv for `az repos pr create`. * * Invariants that prevent the feature→main incident (#451 / #467): * - `--target-branch` is ALWAYS passed (az silently defaults to the repository's default * branch — usually `main` — when it is absent). * - the greedy `--description` (nargs='+') is the LAST flag, so it can never swallow the * `--source-branch` / `--target-branch` tokens (the exact cause of #467: a greedy * `--description` placed before them ate `--target-branch develop`, so az defaulted to main * while `--output json` — a global arg — still produced parseable JSON, masking the loss). * - the multi-line body is split into ONE value per line, so no embedded newline travels * through the Windows `cmd.exe` shell. * - org/project/repo are passed explicitly when known, instead of relying on `az` cwd detection. * - the description is capped at AZURE_PR_DESCRIPTION_MAX (the release/5.16.0 incident: * five tour-6 commit subjects totalled 6 499 chars, Azure refused the create — and the * stderr-only error came back as an empty string until toolError()). */ export function buildAzurePrCreateArgs(opts: PROptions): string[] { const args = [ 'repos', 'pr', 'create', '--title', opts.title, '--source-branch', opts.source, '--target-branch', opts.target, '--output', 'json', ]; if (opts.azure) { args.push( '--organization', `https://dev.azure.com/${opts.azure.org}`, '--project', opts.azure.project, '--repository', opts.azure.repo, ); } if (opts.draft) args.push('--draft', 'true'); // MUST stay last (greedy nargs='+'); one value per line avoids embedded newlines. const descLines = capAzureDescriptionLines( (opts.body ?? '').split(/\r?\n/).filter((line) => line.trim().length > 0), ); if (descLines.length > 0) args.push('--description', ...descLines); return args; } /** Build the argv for `az repos pr update --status abandoned` (auto-abandon a mis-targeted PR). */ export function buildAzurePrAbandonArgs(prNumber: number, azure?: AzureDevOpsInfo): string[] { const args = ['repos', 'pr', 'update', '--id', String(prNumber), '--status', 'abandoned', '--output', 'json']; if (azure) { args.push('--organization', `https://dev.azure.com/${azure.org}`, '--project', azure.project); } return args; } export async function createPR(opts: PROptions, cwd?: string): Promise<{ success: boolean; pr?: PRInfo; error?: string }> { if (opts.provider === 'github') { if (!(await isToolAvailable('gh'))) { return { success: false, error: 'GitHub CLI (gh) not found. Install: https://cli.github.com' }; } const args = ['pr', 'create', '--title', opts.title, '--body', opts.body, '--base', opts.target, '--head', opts.source]; if (opts.draft) args.push('--draft'); const result = await execTool('gh', args, cwd); if (result.exitCode !== 0) return { success: false, error: toolError(result) }; const url = result.stdout; const numberMatch = url.match(/\/pull\/(\d+)/); return { success: true, pr: { number: numberMatch ? parseInt(numberMatch[1], 10) : 0, url, title: opts.title, state: 'open', source: opts.source, target: opts.target }, }; } if (opts.provider === 'azuredevops') { if (!(await isToolAvailable('az'))) { return { success: false, error: 'Azure CLI (az) not found. Install: https://aka.ms/installazurecli' }; } const args = buildAzurePrCreateArgs(opts); const result = await execTool('az', args, cwd); if (result.exitCode !== 0) return { success: false, error: toolError(result) }; try { const pr = JSON.parse(result.stdout); return { success: true, pr: { number: pr.pullRequestId, url: pr.url || '', title: opts.title, state: pr.status || 'active', source: opts.source, target: opts.target }, }; } catch { return { success: false, error: 'Failed to parse Azure DevOps PR response' }; } } return { success: false, error: `Unsupported provider: ${opts.provider}` }; } export async function findPR(branch: string, provider: GitProvider, cwd?: string): Promise { if (provider === 'github') { const result = await execTool('gh', ['pr', 'view', branch, '--json', 'number,url,title,state,headRefName,baseRefName'], cwd); if (result.exitCode !== 0) return null; try { const pr = JSON.parse(result.stdout); return { number: pr.number, url: pr.url, title: pr.title, state: pr.state, source: pr.headRefName, target: pr.baseRefName }; } catch { return null; } } if (provider === 'azuredevops') { const result = await execTool('az', ['repos', 'pr', 'list', '--source-branch', branch, '--output', 'json', '--top', '1'], cwd); if (result.exitCode !== 0) return null; try { const prs = JSON.parse(result.stdout); if (!Array.isArray(prs) || prs.length === 0) return null; const pr = prs[0]; return { number: pr.pullRequestId, url: pr.url || '', title: pr.title, state: pr.status, source: pr.sourceRefName?.replace('refs/heads/', '') || branch, target: pr.targetRefName?.replace('refs/heads/', '') || '' }; } catch { return null; } } return null; } /** * Read back a PR BY NUMBER to get its server-side target branch — used after * createPR to verify the created PR actually targets the intended branch * (defense against the feature→main incident where the tool reported one * target but the provider created another). */ export async function getPR(prNumber: number, provider: GitProvider, cwd?: string): Promise { if (provider === 'github') { const result = await execTool('gh', ['pr', 'view', String(prNumber), '--json', 'number,url,title,state,headRefName,baseRefName'], cwd); if (result.exitCode !== 0) return null; try { const pr = JSON.parse(result.stdout); return { number: pr.number, url: pr.url, title: pr.title, state: pr.state, source: pr.headRefName, target: pr.baseRefName }; } catch { return null; } } if (provider === 'azuredevops') { const result = await execTool('az', ['repos', 'pr', 'show', '--id', String(prNumber), '--output', 'json'], cwd); if (result.exitCode !== 0) return null; try { const pr = JSON.parse(result.stdout); return { number: pr.pullRequestId, url: pr.url || '', title: pr.title, state: pr.status, source: (pr.sourceRefName || '').replace('refs/heads/', ''), target: (pr.targetRefName || '').replace('refs/heads/', ''), }; } catch { return null; } } return null; } export async function mergePR( prNumber: number, provider: GitProvider, strategy: 'squash' | 'merge', cwd?: string, ): Promise<{ success: boolean; error?: string }> { if (provider === 'github') { const strategyFlag = strategy === 'squash' ? '--squash' : '--merge'; const result = await execTool('gh', ['pr', 'merge', String(prNumber), strategyFlag, '--delete-branch'], cwd); return { success: result.exitCode === 0, error: result.exitCode !== 0 ? toolError(result) : undefined }; } if (provider === 'azuredevops') { const squash = strategy === 'squash' ? 'true' : 'false'; const update = await execTool('az', ['repos', 'pr', 'update', '--id', String(prNumber), '--status', 'completed', '--squash', squash, '--delete-source-branch', 'true', '--output', 'json'], cwd); const verdict = interpretAzurePrCompletion(update); if (verdict.reason !== 'unreadable') return { success: verdict.success, error: verdict.error }; // The update printed nothing readable — ask Azure for the PR itself rather than // guessing; its `status` is the only proof the merge happened. const show = await execTool('az', ['repos', 'pr', 'show', '--id', String(prNumber), '--output', 'json'], cwd); const proof = interpretAzurePrCompletion(show); return { success: proof.success, error: proof.error }; } return { success: false, error: `Unsupported provider: ${provider}` }; } export type AzurePrCompletionVerdict = { success: boolean; error?: string; /** Why it is not a success — lets the caller decide whether a second look is worth it. */ reason?: 'exit' | 'unreadable' | 'not-completed'; }; /** * Read the PR JSON that `az repos pr update --status completed` (or `az repos pr show`) * prints and decide whether the merge REALLY happened. * * Azure ACCEPTS a completion request it cannot honour — merge conflicts, a failing * policy — with exit 0 and the untouched PR (`status: "active"`) on stdout. Judging * by the exit code alone reported PR 510 (release/5.20.0 → main, 2026-09-05) as * merged while main had not moved; the finish that followed would have tagged the * wrong commit. Only `status === "completed"` counts. Unreadable output is NOT a * success either — the caller must fetch the PR and look again. */ export function interpretAzurePrCompletion(result: { stdout: string; stderr: string; exitCode: number }): AzurePrCompletionVerdict { if (result.exitCode !== 0) return { success: false, error: toolError(result), reason: 'exit' }; let payload: unknown = null; try { payload = JSON.parse(result.stdout); } catch { payload = null; } if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { const shown = result.stdout.slice(0, 200) || result.stderr.slice(0, 200) || '(empty output)'; return { success: false, error: `az returned no readable PR JSON — completion unverified: ${shown}`, reason: 'unreadable' }; } const pr = payload as { status?: unknown; mergeStatus?: unknown; pullRequestId?: unknown }; const status = typeof pr.status === 'string' ? pr.status : ''; if (status === 'completed') return { success: true }; const mergeStatus = typeof pr.mergeStatus === 'string' ? pr.mergeStatus : 'unknown'; const hint = mergeStatus === 'conflicts' ? 'Bring the target branch into the source branch (merge origin/, resolve, push), then retry merge.' : 'Check the PR on Azure DevOps (policies, required build, reviewers), then retry merge.'; const id = pr.pullRequestId !== undefined ? ` #${String(pr.pullRequestId)}` : ''; return { success: false, error: `PR${id} was NOT completed — Azure left it '${status || 'unknown'}' (mergeStatus: ${mergeStatus}). ${hint}`, reason: 'not-completed', }; } /** * Abandon (azuredevops) / close (github) a PR by number — used to auto-clean a PR the * provider created against the WRONG target branch, so a feature→main PR never lingers * on the server waiting for a human to notice it. */ export async function abandonPR( prNumber: number, provider: GitProvider, cwd?: string, azure?: AzureDevOpsInfo, ): Promise<{ success: boolean; error?: string }> { if (provider === 'github') { const result = await execTool('gh', ['pr', 'close', String(prNumber)], cwd); return { success: result.exitCode === 0, error: result.exitCode !== 0 ? toolError(result) : undefined }; } if (provider === 'azuredevops') { const result = await execTool('az', buildAzurePrAbandonArgs(prNumber, azure), cwd); return { success: result.exitCode === 0, error: result.exitCode !== 0 ? toolError(result) : undefined }; } return { success: false, error: `Unsupported provider: ${provider}` }; }