Skip to content

Commit ba8f708

Browse files
authored
feat(web-ui): PROOF9-gated merge button on PR Status panel (#571)
## Summary - PROOF9-gated [Merge] button added to PRStatusPanel - Disabled when CI failing or open PROOF9 requirements exist - Inline blocking messages with links to open requirements - Success banner + polling stop on merge - Optimistic SWR cache update after merge - Button disabled during initial data load ## Validation - Review feedback: All addressed (2 rounds — CodeRabbit + claude-review) - Demo: All 9 acceptance criteria verified via test suite - Tests: 756/756 passing - CI: All checks green - Linting: Clean Closes #571
1 parent 27b15b6 commit ba8f708

4 files changed

Lines changed: 362 additions & 9 deletions

File tree

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
2+
import { PRStatusPanel } from '@/components/review/PRStatusPanel';
3+
4+
jest.mock('@/lib/api', () => ({
5+
prApi: {
6+
getStatus: jest.fn(),
7+
merge: jest.fn(),
8+
},
9+
proofApi: {
10+
getStatus: jest.fn(),
11+
},
12+
}));
13+
14+
jest.mock('@/lib/workspace-storage', () => ({
15+
getSelectedWorkspacePath: jest.fn(() => '/test/workspace'),
16+
}));
17+
18+
jest.mock('swr', () => ({ __esModule: true, default: jest.fn() }));
19+
20+
import useSWR from 'swr';
21+
import { prApi } from '@/lib/api';
22+
23+
const mockUseSWR = useSWR as jest.MockedFunction<typeof useSWR>;
24+
const mockMerge = prApi.merge as jest.MockedFunction<typeof prApi.merge>;
25+
26+
// ── Fixtures ──────────────────────────────────────────────────────────────────
27+
28+
const successfulCIChecks = [
29+
{ name: 'tests', status: 'completed', conclusion: 'success' },
30+
{ name: 'lint', status: 'completed', conclusion: 'success' },
31+
];
32+
33+
const failingCIChecks = [
34+
{ name: 'tests', status: 'completed', conclusion: 'failure' },
35+
];
36+
37+
const basePRStatus = {
38+
ci_checks: successfulCIChecks,
39+
review_status: 'approved',
40+
merge_state: 'open',
41+
pr_url: 'https://github.com/test/repo/pull/42',
42+
pr_number: 42,
43+
};
44+
45+
const openReq = {
46+
id: 'REQ-001',
47+
title: 'Fix critical bug',
48+
status: 'open',
49+
description: 'A test requirement',
50+
severity: 'high',
51+
source: 'manual',
52+
glitch_type: null,
53+
obligations: [],
54+
evidence_rules: [],
55+
waiver: null,
56+
created_at: '2026-01-01T00:00:00Z',
57+
satisfied_at: null,
58+
created_by: 'tester',
59+
source_issue: null,
60+
related_reqs: [],
61+
scope: null,
62+
};
63+
64+
const cleanProofStatus = {
65+
total: 0,
66+
open: 0,
67+
satisfied: 0,
68+
waived: 0,
69+
requirements: [],
70+
};
71+
72+
const proofStatusWithOpenReqs = {
73+
total: 1,
74+
open: 1,
75+
satisfied: 0,
76+
waived: 0,
77+
requirements: [openReq],
78+
};
79+
80+
// ── Helpers ───────────────────────────────────────────────────────────────────
81+
82+
const setupSWRMock = (prStatus: object, proofStatus: object) => {
83+
mockUseSWR.mockImplementation((key: unknown) => {
84+
const keyStr = typeof key === 'string' ? key : '';
85+
if (keyStr.includes('/api/v2/proof/status')) {
86+
return { data: proofStatus, error: undefined, isLoading: false, mutate: jest.fn() } as any;
87+
}
88+
return { data: prStatus, error: undefined, isLoading: false, mutate: jest.fn() } as any;
89+
});
90+
};
91+
92+
const defaultProps = {
93+
prNumber: 42,
94+
workspacePath: '/test/workspace',
95+
};
96+
97+
// ── Tests ─────────────────────────────────────────────────────────────────────
98+
99+
describe('PRStatusPanel — PROOF9-gated merge button', () => {
100+
beforeEach(() => {
101+
jest.clearAllMocks();
102+
});
103+
104+
it('disables merge button when PROOF9 has open requirements', () => {
105+
setupSWRMock(basePRStatus, proofStatusWithOpenReqs);
106+
render(<PRStatusPanel {...defaultProps} />);
107+
expect(screen.getByRole('button', { name: /^merge$/i })).toBeDisabled();
108+
});
109+
110+
it('shows blocking REQ titles inline when PROOF9 has open requirements', () => {
111+
setupSWRMock(basePRStatus, proofStatusWithOpenReqs);
112+
render(<PRStatusPanel {...defaultProps} />);
113+
expect(screen.getByText('Fix critical bug')).toBeInTheDocument();
114+
});
115+
116+
it('shows link to /proof page when PROOF9 is blocking', () => {
117+
setupSWRMock(basePRStatus, proofStatusWithOpenReqs);
118+
render(<PRStatusPanel {...defaultProps} />);
119+
expect(screen.getByRole('link', { name: /view all/i })).toHaveAttribute('href', '/proof');
120+
});
121+
122+
it('enables merge button when all requirements are cleared and CI passes', () => {
123+
setupSWRMock(basePRStatus, cleanProofStatus);
124+
render(<PRStatusPanel {...defaultProps} />);
125+
expect(screen.getByRole('button', { name: /^merge$/i })).not.toBeDisabled();
126+
});
127+
128+
it('shows success banner and removes merge button after successful merge', async () => {
129+
setupSWRMock(basePRStatus, cleanProofStatus);
130+
mockMerge.mockResolvedValueOnce({ sha: 'abc123', merged: true, message: 'Merged!' });
131+
render(<PRStatusPanel {...defaultProps} />);
132+
133+
fireEvent.click(screen.getByRole('button', { name: /^merge$/i }));
134+
135+
await waitFor(() => {
136+
expect(screen.getByText(/merged successfully/i)).toBeInTheDocument();
137+
});
138+
expect(screen.queryByRole('button', { name: /merge/i })).not.toBeInTheDocument();
139+
});
140+
141+
it('shows error message and re-enables button when merge API call fails', async () => {
142+
setupSWRMock(basePRStatus, cleanProofStatus);
143+
mockMerge.mockRejectedValueOnce({ detail: 'Cannot merge: conflicts detected' });
144+
render(<PRStatusPanel {...defaultProps} />);
145+
146+
fireEvent.click(screen.getByRole('button', { name: /^merge$/i }));
147+
148+
await waitFor(() => {
149+
expect(screen.getByText(/cannot merge/i)).toBeInTheDocument();
150+
});
151+
expect(screen.getByRole('button', { name: /^merge$/i })).toBeInTheDocument();
152+
});
153+
154+
it('disables merge button and shows loading text while merge is in-flight', async () => {
155+
setupSWRMock(basePRStatus, cleanProofStatus);
156+
let resolveMerge!: (val: unknown) => void;
157+
const mergePromise = new Promise((resolve) => {
158+
resolveMerge = resolve;
159+
});
160+
mockMerge.mockReturnValueOnce(mergePromise as any);
161+
render(<PRStatusPanel {...defaultProps} />);
162+
163+
fireEvent.click(screen.getByRole('button', { name: /^merge$/i }));
164+
165+
await waitFor(() => {
166+
expect(screen.getByRole('button', { name: /merging/i })).toBeDisabled();
167+
});
168+
169+
resolveMerge({ sha: 'abc', merged: true, message: 'ok' });
170+
171+
await waitFor(() => {
172+
expect(screen.getByText(/merged successfully/i)).toBeInTheDocument();
173+
});
174+
});
175+
176+
it('shows CI blocking message when CI checks are failing', () => {
177+
setupSWRMock({ ...basePRStatus, ci_checks: failingCIChecks }, cleanProofStatus);
178+
render(<PRStatusPanel {...defaultProps} />);
179+
expect(screen.getByText(/ci checks failing/i)).toBeInTheDocument();
180+
});
181+
182+
it('shows both CI and PROOF9 blocking messages when both are blocking', () => {
183+
setupSWRMock({ ...basePRStatus, ci_checks: failingCIChecks }, proofStatusWithOpenReqs);
184+
render(<PRStatusPanel {...defaultProps} />);
185+
expect(screen.getByText(/ci checks failing/i)).toBeInTheDocument();
186+
expect(screen.getByText('Fix critical bug')).toBeInTheDocument();
187+
});
188+
});

web-ui/src/components/review/PRStatusPanel.tsx

Lines changed: 149 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,20 @@
11
'use client';
22

3+
import { useState } from 'react';
4+
import Link from 'next/link';
35
import useSWR from 'swr';
4-
import { prApi } from '@/lib/api';
6+
import { Loading03Icon, CheckmarkCircle01Icon } from '@hugeicons/react';
7+
import { prApi, proofApi } from '@/lib/api';
58
import { Badge } from '@/components/ui/badge';
69
import { Card } from '@/components/ui/card';
7-
import type { CICheck, PRStatusResponse } from '@/types';
10+
import { Button } from '@/components/ui/button';
11+
import {
12+
Tooltip,
13+
TooltipContent,
14+
TooltipProvider,
15+
TooltipTrigger,
16+
} from '@/components/ui/tooltip';
17+
import type { CICheck, PRStatusResponse, ProofRequirement, ProofStatusResponse } from '@/types';
818

919
// ── Badge variant mappings ────────────────────────────────────────────────
1020

@@ -55,23 +65,28 @@ const MERGE_BADGE: Record<string, { variant: BadgeVariant; label: string }> = {
5565
open: { variant: 'in-progress', label: 'Open' },
5666
};
5767

58-
// ── Component ─────────────────────────────────────────────────────────────
68+
// ── Component ─────────────────────────────────────────────────────────────────
5969

6070
export interface PRStatusPanelProps {
6171
prNumber: number;
6272
workspacePath: string;
6373
}
6474

6575
export function PRStatusPanel({ prNumber, workspacePath }: PRStatusPanelProps) {
76+
const [isMerging, setIsMerging] = useState(false);
77+
const [merged, setMerged] = useState(false);
78+
const [mergeError, setMergeError] = useState<string | null>(null);
79+
6680
const swrKey = `/api/v2/pr/status?workspace_path=${encodeURIComponent(workspacePath)}&pr_number=${prNumber}`;
81+
const proofKey = `/api/v2/proof/status?workspace_path=${encodeURIComponent(workspacePath)}`;
6782

68-
const { data, error } = useSWR<PRStatusResponse>(
83+
const { data, error, mutate: mutatePRStatus } = useSWR<PRStatusResponse>(
6984
swrKey,
7085
() => prApi.getStatus(workspacePath, prNumber),
7186
{
72-
// Stop polling once the PR is merged or closed.
7387
refreshInterval: (latestData) => {
7488
if (
89+
merged ||
7590
latestData?.merge_state === 'merged' ||
7691
latestData?.merge_state === 'closed'
7792
) {
@@ -82,6 +97,51 @@ export function PRStatusPanel({ prNumber, workspacePath }: PRStatusPanelProps) {
8297
}
8398
);
8499

100+
const { data: proofData } = useSWR<ProofStatusResponse>(
101+
proofKey,
102+
() => proofApi.getStatus(workspacePath),
103+
{ refreshInterval: merged ? 0 : 15_000 }
104+
);
105+
106+
// ── Gate logic ────────────────────────────────────────────────────────────
107+
108+
const openRequirements: ProofRequirement[] = (proofData?.requirements ?? []).filter(
109+
(r) => r.status === 'open'
110+
);
111+
112+
const ciFailing = (data?.ci_checks ?? []).some(
113+
(c) =>
114+
c.conclusion === 'failure' ||
115+
c.conclusion === 'timed_out' ||
116+
c.conclusion === 'action_required'
117+
);
118+
119+
const ciPending = (data?.ci_checks ?? []).some(
120+
(c) => c.status === 'in_progress' || c.status === 'queued'
121+
);
122+
123+
const ciPassing = !ciFailing && !ciPending;
124+
const canMerge = !!data && !!proofData && openRequirements.length === 0 && ciPassing;
125+
126+
// ── Merge handler ─────────────────────────────────────────────────────────
127+
128+
const handleMerge = async () => {
129+
setIsMerging(true);
130+
setMergeError(null);
131+
try {
132+
await prApi.merge(workspacePath, prNumber, { method: 'squash' });
133+
setMerged(true);
134+
mutatePRStatus((prev) => prev ? { ...prev, merge_state: 'merged' } : prev, false);
135+
} catch (err: unknown) {
136+
const apiErr = err as { detail?: string };
137+
setMergeError(apiErr?.detail ?? 'Merge failed. Please try again.');
138+
} finally {
139+
setIsMerging(false);
140+
}
141+
};
142+
143+
// ── Render ────────────────────────────────────────────────────────────────
144+
85145
const reviewBadge = REVIEW_BADGE[data?.review_status ?? 'pending'] ?? REVIEW_BADGE.pending;
86146
const mergeBadge = MERGE_BADGE[data?.merge_state ?? 'open'] ?? MERGE_BADGE.open;
87147

@@ -93,10 +153,7 @@ export function PRStatusPanel({ prNumber, workspacePath }: PRStatusPanelProps) {
93153
{!data && !error && (
94154
<div className="flex flex-col gap-2">
95155
{[1, 2, 3].map((i) => (
96-
<div
97-
key={i}
98-
className="h-5 animate-pulse rounded bg-muted"
99-
/>
156+
<div key={i} className="h-5 animate-pulse rounded bg-muted" />
100157
))}
101158
</div>
102159
)}
@@ -143,8 +200,91 @@ export function PRStatusPanel({ prNumber, workspacePath }: PRStatusPanelProps) {
143200
</div>
144201
)}
145202
</div>
203+
204+
{/* PROOF9 gate section */}
205+
<div className="flex flex-col gap-1.5">
206+
<span className="text-sm font-medium">PROOF9</span>
207+
{openRequirements.length === 0 ? (
208+
<p className="flex items-center gap-1 text-xs text-muted-foreground">
209+
<CheckmarkCircle01Icon className="h-3 w-3 text-green-600" />
210+
All clear
211+
</p>
212+
) : (
213+
<div className="flex flex-col gap-1">
214+
{openRequirements.map((req) => (
215+
<Link
216+
key={req.id}
217+
href={`/proof/${req.id}`}
218+
className="text-xs text-red-600 hover:underline"
219+
>
220+
{req.title}
221+
</Link>
222+
))}
223+
<Link
224+
href="/proof"
225+
className="mt-1 text-xs text-muted-foreground hover:underline"
226+
>
227+
View all →
228+
</Link>
229+
</div>
230+
)}
231+
</div>
146232
</>
147233
)}
234+
235+
{/* Blocking messages */}
236+
{data && (ciFailing || ciPending) && !merged && (
237+
<p className="text-xs text-amber-600">
238+
{ciFailing ? 'CI checks failing' : 'Waiting for CI checks'}
239+
</p>
240+
)}
241+
242+
{/* Merge error banner */}
243+
{mergeError && (
244+
<div className="rounded bg-red-50 px-3 py-2 text-xs text-red-700">
245+
{mergeError}
246+
</div>
247+
)}
248+
249+
{/* Success banner or Merge button */}
250+
{merged ? (
251+
<div className="flex items-center gap-1 rounded bg-green-50 px-3 py-2 text-xs text-green-700">
252+
<CheckmarkCircle01Icon className="h-3 w-3" />
253+
PR #{prNumber} merged successfully
254+
</div>
255+
) : (
256+
<TooltipProvider>
257+
<Tooltip>
258+
<TooltipTrigger asChild>
259+
{/* Wrap in span so tooltip fires even when button is disabled */}
260+
<span className="w-full">
261+
<Button
262+
onClick={handleMerge}
263+
disabled={!canMerge || isMerging}
264+
size="sm"
265+
className="w-full transition-all"
266+
>
267+
{isMerging ? (
268+
<>
269+
<Loading03Icon className="mr-1.5 h-4 w-4 animate-spin" />
270+
Merging...
271+
</>
272+
) : (
273+
'Merge'
274+
)}
275+
</Button>
276+
</span>
277+
</TooltipTrigger>
278+
{!canMerge && (
279+
<TooltipContent>
280+
{openRequirements.length > 0 && 'Resolve all open PROOF9 requirements. '}
281+
{ciFailing && 'Fix failing CI checks. '}
282+
{ciPending && 'Wait for CI checks to complete.'}
283+
</TooltipContent>
284+
)}
285+
</Tooltip>
286+
</TooltipProvider>
287+
)}
148288
</Card>
149289
);
150290
}

0 commit comments

Comments
 (0)