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;
|
if (!job) return;
|
||||||
setShipping(true);
|
setShipping(true);
|
||||||
try {
|
try {
|
||||||
const updated = await patchJob(jobId, { leaseEpoch: job.leaseEpoch, stage: 'shipped' });
|
await patchJob(jobId, { leaseEpoch: job.leaseEpoch, stage: 'shipped' });
|
||||||
setJob(updated);
|
await refresh(); // pull updated job + runs (PR may now be merged)
|
||||||
} catch {
|
} catch {
|
||||||
/* show error in production */
|
/* show error in production */
|
||||||
} finally {
|
} finally {
|
||||||
@ -197,7 +197,11 @@ export default function FleetJobDetailPage() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{job.stage !== 'shipped' && job.stage !== 'failed' && (
|
{job.stage !== 'shipped' && job.stage !== 'failed' && (
|
||||||
<Button onClick={handleShip} disabled={shipping} aria-label="Ship this job">
|
<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>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{job.stage === 'building' && (
|
{job.stage === 'building' && (
|
||||||
|
|||||||
@ -18,6 +18,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
|
import { execFile } from 'node:child_process';
|
||||||
|
import { promisify } from 'node:util';
|
||||||
import { BadRequestError, ConflictError } from '../../lib/errors.js';
|
import { BadRequestError, ConflictError } from '../../lib/errors.js';
|
||||||
import * as repo from './repository.js';
|
import * as repo from './repository.js';
|
||||||
import {
|
import {
|
||||||
@ -633,6 +635,27 @@ async function markLatestRunShipped(jobId: string): Promise<FleetRunDoc | undefi
|
|||||||
return latest;
|
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(
|
export async function patchJobFenced(
|
||||||
jobId: string,
|
jobId: string,
|
||||||
productId: string,
|
productId: string,
|
||||||
@ -670,6 +693,8 @@ export async function patchJobFenced(
|
|||||||
try {
|
try {
|
||||||
// Run-level result mirrors the terminal stage (ungated).
|
// Run-level result mirrors the terminal stage (ungated).
|
||||||
const latest = await markLatestRunShipped(jobId);
|
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.
|
// Budgets (flag-gated): accrue the run's actual cost, idempotent per run.
|
||||||
if (isBudgetsEnabled()) {
|
if (isBudgetsEnabled()) {
|
||||||
await accrueSpend(productId, latest?.insights?.costUsd ?? 0, `${jobId}:${job.leaseEpoch}`);
|
await accrueSpend(productId, latest?.insights?.costUsd ?? 0, `${jobId}:${job.leaseEpoch}`);
|
||||||
@ -1015,10 +1040,12 @@ export async function operatorAction(
|
|||||||
data: { action, leaseEpoch: newEpoch, returnedTo: stage },
|
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') {
|
if (stage === 'shipped') {
|
||||||
try {
|
try {
|
||||||
await markLatestRunShipped(jobId);
|
const latest = await markLatestRunShipped(jobId);
|
||||||
|
await mergeRunPrOnShip(jobId, latest);
|
||||||
} catch {
|
} catch {
|
||||||
// best-effort — run bookkeeping never fails the operator action
|
// best-effort — run bookkeeping never fails the operator action
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user