From 740335a1493ab279aa7d3853ec9411a18dd4ec83 Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Sun, 31 May 2026 16:08:28 -0700 Subject: [PATCH] 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. --- .../app/dashboard/fleet/jobs/[id]/page.tsx | 10 ++++-- .../src/modules/fleet/coordinator.ts | 31 +++++++++++++++++-- 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/dashboards/tracker-web/src/app/dashboard/fleet/jobs/[id]/page.tsx b/dashboards/tracker-web/src/app/dashboard/fleet/jobs/[id]/page.tsx index 41370ecc..68e00d95 100644 --- a/dashboards/tracker-web/src/app/dashboard/fleet/jobs/[id]/page.tsx +++ b/dashboards/tracker-web/src/app/dashboard/fleet/jobs/[id]/page.tsx @@ -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() {
{job.stage !== 'shipped' && job.stage !== 'failed' && ( )} {job.stage === 'building' && ( diff --git a/services/platform-service/src/modules/fleet/coordinator.ts b/services/platform-service/src/modules/fleet/coordinator.ts index 6bc0d692..3d985711 100644 --- a/services/platform-service/src/modules/fleet/coordinator.ts +++ b/services/platform-service/src/modules/fleet/coordinator.ts @@ -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 { + 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 }