feat(fleet): reconcile PR state against GitHub (detect externally-merged PR)
A PR merged in the GitHub UI was invisible to the platform — prState only flipped to `merged` when the platform merged it (ghMergePr on ship) or a runner reported it, so the job details page kept showing the PR as open. This adds a simple, pull-based reconcile (no inbound webhook / public ingress needed). - coordinator.reconcileJobPrState(jobId, productId, fetcher?): finds the latest run carrying a prUrl and, when `gh pr view --json state` reports MERGED, flips the run's prState to `merged` and appends a `pr_merged` event (data.via: 'reconcile'). The GitHub lookup is injectable for tests; pure `mapGhPrState` maps MERGED/OPEN/other. Best-effort: any gh failure is a no-op. - POST /fleet/jobs/:id/pr/reconcile route; echoes the outcome to the tracker Item when a merge is detected. - tracker-web: reconcilePrState() client + a "Refresh PR status" button on the job details PR section (shown until the PR is merged) that reconciles then refreshes the view. Tests: +5 (mapGhPrState, reconcile merged/open/no_pr/not_found, route wiring); full suite 1861 green; lint + tsc clean (service + tracker-web). Deployed: rebuilt the docker platform-service; POST .../pr/reconcile returns 401 (wired), not 404. Generated with [Devin](https://cli.devin.ai/docs) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
c63736459b
commit
6bddc88f0f
@ -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<OperatorAction | null>(null);
|
||||
const [reviewing, setReviewing] = useState(false);
|
||||
const [reconcilingPr, setReconcilingPr] = useState(false);
|
||||
const [eventFilter, setEventFilter] = useState<string>('');
|
||||
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}
|
||||
</span>
|
||||
)}
|
||||
{prRun.prState !== 'merged' && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleReconcilePr}
|
||||
disabled={reconcilingPr}
|
||||
aria-label="Check GitHub for the PR's current merge state"
|
||||
title="Detect a PR merged directly in GitHub and update its state here"
|
||||
>
|
||||
{reconcilingPr ? 'Checking...' : 'Refresh PR status'}
|
||||
</Button>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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<PrReconcileResult> {
|
||||
return apiFetch(`/jobs/${jobId}/pr/reconcile`, { method: 'POST' });
|
||||
}
|
||||
|
||||
export async function getJobEvents(jobId: string): Promise<{ events: FleetEvent[] }> {
|
||||
return apiFetch(`/jobs/${jobId}/events`);
|
||||
}
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -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<FleetRunDoc['prState'] | null> {
|
||||
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<FleetRunDoc['prState'] | null> = fetchGhPrState
|
||||
): Promise<PrReconcileResult> {
|
||||
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,
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user