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:
parent
37d049eb69
commit
740335a149
@ -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' && (
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user