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 706bb46e..9ccbf80d 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 @@ -20,6 +20,7 @@ import { submitDraft, requestReview, submitReview, + reconcilePrState, subscribeJobEvents, FLEET_ENGINES, type OperatorAction, @@ -47,6 +48,7 @@ export default function FleetJobDetailPage() { const [shipping, setShipping] = useState(false); const [acting, setActing] = useState(null); const [reviewing, setReviewing] = useState(false); + const [reconcilingPr, setReconcilingPr] = useState(false); const [eventFilter, setEventFilter] = useState(''); const [streamMode, setStreamMode] = useState<'connecting' | 'live' | 'polling'>('connecting'); @@ -160,6 +162,18 @@ export default function FleetJobDetailPage() { } }; + const handleReconcilePr = async () => { + setReconcilingPr(true); + try { + await reconcilePrState(jobId); + await refresh(); // pull the (possibly now merged) run state + } catch { + /* show error in production */ + } finally { + setReconcilingPr(false); + } + }; + const handleReview = async (decision: 'approve' | 'reject') => { if (!job || !user) return; setReviewing(true); @@ -302,6 +316,17 @@ export default function FleetJobDetailPage() { {prRun.prState} )} + {prRun.prState !== 'merged' && ( + + )} ); } diff --git a/dashboards/tracker-web/src/lib/fleet-client.ts b/dashboards/tracker-web/src/lib/fleet-client.ts index 916cb280..fae206ee 100644 --- a/dashboards/tracker-web/src/lib/fleet-client.ts +++ b/dashboards/tracker-web/src/lib/fleet-client.ts @@ -336,6 +336,21 @@ export async function getJobRuns(jobId: string): Promise<{ runs: FleetRun[] }> { return apiFetch(`/jobs/${jobId}/runs`); } +export interface PrReconcileResult { + ok: boolean; + updated: boolean; + prState?: 'open' | 'merged'; + reason?: 'not_found' | 'no_pr' | 'already_merged' | 'unchanged'; +} + +/** + * Reconcile the job's PR state against GitHub (detect a PR merged in the GitHub + * UI). Flips the run's prState to `merged` server-side when `gh` reports MERGED. + */ +export async function reconcilePrState(jobId: string): Promise { + return apiFetch(`/jobs/${jobId}/pr/reconcile`, { method: 'POST' }); +} + export async function getJobEvents(jobId: string): Promise<{ events: FleetEvent[] }> { return apiFetch(`/jobs/${jobId}/events`); } diff --git a/services/platform-service/src/modules/fleet/coordinator.test.ts b/services/platform-service/src/modules/fleet/coordinator.test.ts index 2e0ab280..e24ef075 100644 --- a/services/platform-service/src/modules/fleet/coordinator.test.ts +++ b/services/platform-service/src/modules/fleet/coordinator.test.ts @@ -1475,6 +1475,61 @@ describe('fleet coordinator — PR target + deliverable (§PR mode)', () => { }); }); +describe('fleet coordinator — PR-state reconcile', () => { + beforeEach(() => setProvider(new MemoryDatastoreProvider())); + afterEach(() => _resetDatastoreProvider()); + + it('mapGhPrState maps GitHub states (MERGED/OPEN/other)', () => { + expect(coord.mapGhPrState('MERGED')).toBe('merged'); + expect(coord.mapGhPrState('merged')).toBe('merged'); + expect(coord.mapGhPrState('OPEN')).toBe('open'); + expect(coord.mapGhPrState('CLOSED')).toBeNull(); + expect(coord.mapGhPrState(undefined)).toBeNull(); + }); + + async function setupOpenPrJob() { + await coord.submitJob(PID, input({ idempotencyKey: 'pr-rec', repo: 'acme/widgets' })); + const claim = await coord.claimNextJob(factory()); + const c = claim!.job; + await coord.releaseLease(c.id, PID, c.leaseEpoch, 'review', { + result: 'shipped', + prUrl: 'https://github.com/acme/widgets/pull/3', + prState: 'open', + }); + return c.id; + } + + it('flips prState to merged + appends a pr_merged event when GitHub reports MERGED', async () => { + const jobId = await setupOpenPrJob(); + const res = await coord.reconcileJobPrState(jobId, PID, async () => 'merged'); + expect(res).toMatchObject({ ok: true, updated: true, prState: 'merged' }); + const runs = await repo.listRunsByJob(jobId); + expect(runs.find(r => r.prUrl)!.prState).toBe('merged'); + const events = await repo.listEvents(jobId); + expect(events.some(e => e.type === 'pr_merged' && e.data?.via === 'reconcile')).toBe(true); + }); + + it('is a no-op when GitHub still reports the PR open', async () => { + const jobId = await setupOpenPrJob(); + const res = await coord.reconcileJobPrState(jobId, PID, async () => 'open'); + expect(res).toMatchObject({ ok: true, updated: false, reason: 'unchanged' }); + const runs = await repo.listRunsByJob(jobId); + expect(runs.find(r => r.prUrl)!.prState).toBe('open'); + }); + + it('returns no_pr when the job has no PR run, and not_found for an unknown job', async () => { + await coord.submitJob(PID, input({ idempotencyKey: 'no-pr' })); + const noPr = await coord.reconcileJobPrState( + (await repo.findJobsByIdempotencyKey(PID, 'no-pr'))[0]!.id, + PID, + async () => 'merged' + ); + expect(noPr).toMatchObject({ ok: true, updated: false, reason: 'no_pr' }); + const missing = await coord.reconcileJobPrState('fjob_missing', PID, async () => 'merged'); + expect(missing).toMatchObject({ ok: false, reason: 'not_found' }); + }); +}); + describe('fleet coordinator — draft lifecycle (save / edit / submit)', () => { beforeEach(() => setProvider(new MemoryDatastoreProvider())); afterEach(() => _resetDatastoreProvider()); diff --git a/services/platform-service/src/modules/fleet/coordinator.ts b/services/platform-service/src/modules/fleet/coordinator.ts index 6eeae93b..1eda5619 100644 --- a/services/platform-service/src/modules/fleet/coordinator.ts +++ b/services/platform-service/src/modules/fleet/coordinator.ts @@ -843,6 +843,91 @@ async function mergeRunPrOnShip(job: FleetJobDoc, latest: FleetRunDoc | undefine }); } +// ── PR-state reconcile (§PR mode — detect an externally-merged PR) ──────────── +// +// The platform only learns a PR is merged when IT merges it (ghMergePr on ship) +// or a runner reports it. A PR merged manually in the GitHub UI is otherwise +// invisible. `reconcileJobPrState` closes that gap on demand: it asks `gh` for +// the PR's current state and flips the run's `prState` to `merged` when GitHub +// says so. Pull/best-effort — no inbound webhook or public ingress required. + +/** Map a GitHub PR state (`gh pr view --json state` → MERGED/OPEN/CLOSED) onto + * our run `prState`. Unknown/empty ⇒ null (leave the run untouched). Pure. */ +export function mapGhPrState(raw: string | undefined): FleetRunDoc['prState'] | null { + switch ((raw ?? '').trim().toUpperCase()) { + case 'MERGED': + return 'merged'; + case 'OPEN': + return 'open'; + default: + return null; // CLOSED (not merged) or unknown — no prState change + } +} + +/** Query the live PR state via the GitHub CLI. Best-effort: any failure ⇒ null. */ +async function fetchGhPrState(prUrl: string): Promise { + try { + const { stdout } = await execFileAsync('gh', ['pr', 'view', prUrl, '--json', 'state'], { + timeout: 30_000, + }); + const parsed = JSON.parse(stdout) as { state?: string }; + return mapGhPrState(parsed.state); + } catch { + return null; + } +} + +export interface PrReconcileResult { + /** false when the job/PR could not be found. */ + ok: boolean; + /** Whether a run was updated (prState flipped to `merged`). */ + updated: boolean; + /** The PR state after reconcile (current stored value when unchanged). */ + prState?: FleetRunDoc['prState']; + reason?: 'not_found' | 'no_pr' | 'already_merged' | 'unchanged'; +} + +/** + * Reconcile a job's PR state against GitHub. Finds the latest run carrying a + * `prUrl`; if it is not already `merged` and `gh` reports the PR MERGED, flips + * the run's `prState` to `merged` and appends a `pr_merged` event. The GitHub + * lookup is injectable for tests. + */ +export async function reconcileJobPrState( + jobId: string, + productId: string, + fetcher: (prUrl: string) => Promise = fetchGhPrState +): Promise { + const job = await repo.getJob(jobId, productId); + if (!job) return { ok: false, updated: false, reason: 'not_found' }; + + const runs = await repo.listRunsByJob(jobId); + const prRun = runs + .filter(r => !!r.prUrl) + .reduce< + FleetRunDoc | undefined + >((acc, r) => (!acc || r.attempt > acc.attempt ? r : acc), undefined); + if (!prRun?.prUrl) return { ok: true, updated: false, reason: 'no_pr' }; + if (prRun.prState === 'merged') { + return { ok: true, updated: false, prState: 'merged', reason: 'already_merged' }; + } + + const live = await fetcher(prRun.prUrl); + if (live !== 'merged') { + return { ok: true, updated: false, prState: prRun.prState, reason: 'unchanged' }; + } + + await repo.updateRun(prRun.id, jobId, { prState: 'merged' }); + await repo.appendEvent({ + jobId, + productId, + type: 'pr_merged', + correlationId: job.correlationId, + data: { prUrl: prRun.prUrl, via: 'reconcile' }, + }); + return { ok: true, updated: true, prState: 'merged' }; +} + export async function patchJobFenced( jobId: string, productId: string, diff --git a/services/platform-service/src/modules/fleet/routes.test.ts b/services/platform-service/src/modules/fleet/routes.test.ts index 2a3df8ef..343d973b 100644 --- a/services/platform-service/src/modules/fleet/routes.test.ts +++ b/services/platform-service/src/modules/fleet/routes.test.ts @@ -482,4 +482,23 @@ describe('fleetRoutes', () => { }); expect(resume.statusCode).toBe(403); }); + + it('POST /fleet/jobs/:id/pr/reconcile returns no_pr for a job without a PR, 404 for unknown', async () => { + const app = await buildApp(); + const sub = await submit(app, { idempotencyKey: 'rec-1', bodyMd: '# task' }); + const jobId = JSON.parse(sub.body).job.id as string; + + const res = await app.inject({ + method: 'POST', + url: `/api/fleet/jobs/${jobId}/pr/reconcile`, + }); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body)).toMatchObject({ ok: true, updated: false, reason: 'no_pr' }); + + const missing = await app.inject({ + method: 'POST', + url: '/api/fleet/jobs/fjob_nope/pr/reconcile', + }); + expect(missing.statusCode).toBe(404); + }); }); diff --git a/services/platform-service/src/modules/fleet/routes.ts b/services/platform-service/src/modules/fleet/routes.ts index efaae547..d093702d 100644 --- a/services/platform-service/src/modules/fleet/routes.ts +++ b/services/platform-service/src/modules/fleet/routes.ts @@ -345,6 +345,21 @@ export async function fleetRoutes(app: FastifyInstance) { return res.doc; }); + // ── Reconcile PR state against GitHub (detect an externally-merged PR) ── + // The platform only learns a PR merged when it merges it / a runner reports it; + // a merge done in the GitHub UI is otherwise invisible. This pulls the live PR + // state via `gh` and flips the run's prState to `merged` when GitHub says so. + app.post('/fleet/jobs/:id/pr/reconcile', async req => { + await extractAuth(req); + const { id } = req.params as { id: string }; + const pid = await requireProductAccess(req); + const res = await coordinator.reconcileJobPrState(id, pid); + if (!res.ok) throw new NotFoundError('Job not found'); + // A merge often coincides with a terminal outcome — echo to the tracker Item. + if (res.updated) await trackerBridge.maybeEchoOnTransition(pid, id, req.log); + return res; + }); + // ── Factory heartbeat ── app.post('/fleet/factories/heartbeat', async req => { await extractAuth(req);