feat(fleet): Ship can also merge the linked PR (gh pr merge)

On ship (Ship button / operator action / autoship PATCH), when the run has an open
PR and FLEET_SHIP_MERGES_PR=1, the coordinator squash-merges it via gh (best-effort,
where gh is authed) and marks the run prState=merged. UI button reads 'Ship & merge
PR' when an open PR exists; Ship refreshes runs.
This commit is contained in:
saravanakumardb1 2026-05-31 16:08:28 -07:00
parent 37d049eb69
commit 740335a149
2 changed files with 36 additions and 5 deletions

View File

@ -118,8 +118,8 @@ export default function FleetJobDetailPage() {
if (!job) return;
setShipping(true);
try {
const updated = await patchJob(jobId, { leaseEpoch: job.leaseEpoch, stage: 'shipped' });
setJob(updated);
await patchJob(jobId, { leaseEpoch: job.leaseEpoch, stage: 'shipped' });
await refresh(); // pull updated job + runs (PR may now be merged)
} catch {
/* show error in production */
} finally {
@ -197,7 +197,11 @@ export default function FleetJobDetailPage() {
<div className="flex items-center gap-2">
{job.stage !== 'shipped' && job.stage !== 'failed' && (
<Button onClick={handleShip} disabled={shipping} aria-label="Ship this job">
{shipping ? 'Shipping...' : 'Ship ✓'}
{shipping
? 'Shipping...'
: runs.some(r => r.prUrl && r.prState !== 'merged')
? 'Ship & merge PR ✓'
: 'Ship ✓'}
</Button>
)}
{job.stage === 'building' && (

View File

@ -18,6 +18,8 @@
*/
import { createHash } from 'node:crypto';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { BadRequestError, ConflictError } from '../../lib/errors.js';
import * as repo from './repository.js';
import {
@ -633,6 +635,27 @@ async function markLatestRunShipped(jobId: string): Promise<FleetRunDoc | undefi
return latest;
}
const execFileAsync = promisify(execFile);
/**
* On ship, optionally merge the linked PR via the GitHub CLI (so "Ship" also
* lands the code). Opt-in via FLEET_SHIP_MERGES_PR=1 and only where `gh` is
* authenticated (the coordinator host). Best-effort: a failure leaves the PR open
* and never fails the ship. Marks the run prState=merged on success.
*/
async function mergeRunPrOnShip(jobId: string, latest: FleetRunDoc | undefined): Promise<void> {
if (process.env.FLEET_SHIP_MERGES_PR !== '1') return;
if (!latest?.prUrl || latest.prState === 'merged') return;
try {
await execFileAsync('gh', ['pr', 'merge', latest.prUrl, '--squash', '--delete-branch'], {
timeout: 60_000,
});
await repo.updateRun(latest.id, jobId, { prState: 'merged' });
} catch {
// best-effort — leave the PR open if the merge is blocked (checks, conflicts…)
}
}
export async function patchJobFenced(
jobId: string,
productId: string,
@ -670,6 +693,8 @@ export async function patchJobFenced(
try {
// Run-level result mirrors the terminal stage (ungated).
const latest = await markLatestRunShipped(jobId);
// Optionally merge the linked PR (flag-gated, best-effort).
await mergeRunPrOnShip(jobId, latest);
// Budgets (flag-gated): accrue the run's actual cost, idempotent per run.
if (isBudgetsEnabled()) {
await accrueSpend(productId, latest?.insights?.costUsd ?? 0, `${jobId}:${job.leaseEpoch}`);
@ -1015,10 +1040,12 @@ export async function operatorAction(
data: { action, leaseEpoch: newEpoch, returnedTo: stage },
});
// Operator `ship` is a terminal success — mirror it onto the run result.
// Operator `ship` is a terminal success — mirror it onto the run result and
// optionally merge the linked PR (flag-gated, best-effort).
if (stage === 'shipped') {
try {
await markLatestRunShipped(jobId);
const latest = await markLatestRunShipped(jobId);
await mergeRunPrOnShip(jobId, latest);
} catch {
// best-effort — run bookkeeping never fails the operator action
}