From 0ebd53b3daadbf6d8ea3ea1860cd5d1b3cdbcd30 Mon Sep 17 00:00:00 2001 From: Test User Date: Tue, 14 Apr 2026 09:00:35 -0700 Subject: [PATCH 1/3] feat(web-ui): PROOF9-gated merge button on PR Status panel (#571) Add a merge button to PRStatusPanel that enforces two gates before enabling: all PROOF9 requirements must be satisfied/waived and all CI checks must pass. Polls proof status via proofApi.getStatus alongside the existing PR status polling. Shows inline blocking messages with links to open requirements when PROOF9 is blocking, and CI status text when checks are pending or failing. On successful merge, displays a confirmation banner and stops all polling. - Add MergeResponse / MergePRRequest types - Add prApi.merge() calling POST /api/v2/pr/{n}/merge - Extend PRStatusPanel with proof SWR hook, gate logic, merge button - 9 Jest tests covering all acceptance criteria --- .../components/review/PRStatusPanel.test.tsx | 184 ++++++++++++++++++ .../src/components/review/PRStatusPanel.tsx | 155 ++++++++++++++- web-ui/src/lib/api.ts | 15 ++ web-ui/src/types/index.ts | 10 + 4 files changed, 356 insertions(+), 8 deletions(-) create mode 100644 web-ui/__tests__/components/review/PRStatusPanel.test.tsx diff --git a/web-ui/__tests__/components/review/PRStatusPanel.test.tsx b/web-ui/__tests__/components/review/PRStatusPanel.test.tsx new file mode 100644 index 00000000..2cccbb83 --- /dev/null +++ b/web-ui/__tests__/components/review/PRStatusPanel.test.tsx @@ -0,0 +1,184 @@ +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import { PRStatusPanel } from '@/components/review/PRStatusPanel'; + +jest.mock('@/lib/api', () => ({ + prApi: { + getStatus: jest.fn(), + merge: jest.fn(), + }, + proofApi: { + getStatus: jest.fn(), + }, +})); + +jest.mock('@/lib/workspace-storage', () => ({ + getSelectedWorkspacePath: jest.fn(() => '/test/workspace'), +})); + +jest.mock('swr', () => ({ __esModule: true, default: jest.fn() })); + +import useSWR from 'swr'; +import { prApi } from '@/lib/api'; + +const mockUseSWR = useSWR as jest.MockedFunction; +const mockMerge = prApi.merge as jest.MockedFunction; + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +const successfulCIChecks = [ + { name: 'tests', status: 'completed', conclusion: 'success' }, + { name: 'lint', status: 'completed', conclusion: 'success' }, +]; + +const failingCIChecks = [ + { name: 'tests', status: 'completed', conclusion: 'failure' }, +]; + +const basePRStatus = { + ci_checks: successfulCIChecks, + review_status: 'approved', + merge_state: 'open', + pr_url: 'https://github.com/test/repo/pull/42', + pr_number: 42, +}; + +const openReq = { + id: 'REQ-001', + title: 'Fix critical bug', + status: 'open', + description: 'A test requirement', + severity: 'high', + source: 'manual', + glitch_type: null, + obligations: [], + evidence_rules: [], + waiver: null, + created_at: '2026-01-01T00:00:00Z', + satisfied_at: null, + created_by: 'tester', + source_issue: null, + related_reqs: [], + scope: null, +}; + +const cleanProofStatus = { + total: 0, + open: 0, + satisfied: 0, + waived: 0, + requirements: [], +}; + +const proofStatusWithOpenReqs = { + total: 1, + open: 1, + satisfied: 0, + waived: 0, + requirements: [openReq], +}; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +const setupSWRMock = (prStatus: object, proofStatus: object) => { + mockUseSWR.mockImplementation((key: unknown) => { + const keyStr = typeof key === 'string' ? key : ''; + if (keyStr.includes('/api/v2/proof/status')) { + return { data: proofStatus, error: undefined, isLoading: false, mutate: jest.fn() } as any; + } + return { data: prStatus, error: undefined, isLoading: false, mutate: jest.fn() } as any; + }); +}; + +const defaultProps = { + prNumber: 42, + workspacePath: '/test/workspace', +}; + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('PRStatusPanel — PROOF9-gated merge button', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('disables merge button when PROOF9 has open requirements', () => { + setupSWRMock(basePRStatus, proofStatusWithOpenReqs); + render(); + expect(screen.getByRole('button', { name: /^merge$/i })).toBeDisabled(); + }); + + it('shows blocking REQ titles inline when PROOF9 has open requirements', () => { + setupSWRMock(basePRStatus, proofStatusWithOpenReqs); + render(); + expect(screen.getByText('Fix critical bug')).toBeInTheDocument(); + }); + + it('shows link to /proof page when PROOF9 is blocking', () => { + setupSWRMock(basePRStatus, proofStatusWithOpenReqs); + render(); + expect(screen.getByRole('link', { name: /view all/i })).toHaveAttribute('href', '/proof'); + }); + + it('enables merge button when all requirements are cleared and CI passes', () => { + setupSWRMock(basePRStatus, cleanProofStatus); + render(); + expect(screen.getByRole('button', { name: /^merge$/i })).not.toBeDisabled(); + }); + + it('shows success banner and removes merge button after successful merge', async () => { + setupSWRMock(basePRStatus, cleanProofStatus); + mockMerge.mockResolvedValueOnce({ sha: 'abc123', merged: true, message: 'Merged!' }); + render(); + + fireEvent.click(screen.getByRole('button', { name: /^merge$/i })); + + await waitFor(() => { + expect(screen.getByText(/merged successfully/i)).toBeInTheDocument(); + }); + expect(screen.queryByRole('button', { name: /merge/i })).not.toBeInTheDocument(); + }); + + it('shows error message and re-enables button when merge API call fails', async () => { + setupSWRMock(basePRStatus, cleanProofStatus); + mockMerge.mockRejectedValueOnce({ detail: 'Cannot merge: conflicts detected' }); + render(); + + fireEvent.click(screen.getByRole('button', { name: /^merge$/i })); + + await waitFor(() => { + expect(screen.getByText(/cannot merge/i)).toBeInTheDocument(); + }); + expect(screen.getByRole('button', { name: /^merge$/i })).toBeInTheDocument(); + }); + + it('disables merge button and shows loading text while merge is in-flight', async () => { + setupSWRMock(basePRStatus, cleanProofStatus); + let resolveMerge!: (val: unknown) => void; + const mergePromise = new Promise((resolve) => { + resolveMerge = resolve; + }); + mockMerge.mockReturnValueOnce(mergePromise as any); + render(); + + fireEvent.click(screen.getByRole('button', { name: /^merge$/i })); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /merging/i })).toBeDisabled(); + }); + + resolveMerge({ sha: 'abc', merged: true, message: 'ok' }); + }); + + it('shows CI blocking message when CI checks are failing', () => { + setupSWRMock({ ...basePRStatus, ci_checks: failingCIChecks }, cleanProofStatus); + render(); + expect(screen.getByText(/ci checks failing/i)).toBeInTheDocument(); + }); + + it('shows both CI and PROOF9 blocking messages when both are blocking', () => { + setupSWRMock({ ...basePRStatus, ci_checks: failingCIChecks }, proofStatusWithOpenReqs); + render(); + expect(screen.getByText(/ci checks failing/i)).toBeInTheDocument(); + expect(screen.getByText('Fix critical bug')).toBeInTheDocument(); + }); +}); diff --git a/web-ui/src/components/review/PRStatusPanel.tsx b/web-ui/src/components/review/PRStatusPanel.tsx index 3799e214..9308f7f2 100644 --- a/web-ui/src/components/review/PRStatusPanel.tsx +++ b/web-ui/src/components/review/PRStatusPanel.tsx @@ -1,10 +1,20 @@ 'use client'; +import { useState } from 'react'; +import Link from 'next/link'; import useSWR from 'swr'; -import { prApi } from '@/lib/api'; +import { Loading03Icon, CheckmarkCircle01Icon } from '@hugeicons/react'; +import { prApi, proofApi } from '@/lib/api'; import { Badge } from '@/components/ui/badge'; import { Card } from '@/components/ui/card'; -import type { CICheck, PRStatusResponse } from '@/types'; +import { Button } from '@/components/ui/button'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import type { CICheck, PRStatusResponse, ProofRequirement, ProofStatusResponse } from '@/types'; // ── Badge variant mappings ──────────────────────────────────────────────── @@ -55,7 +65,7 @@ const MERGE_BADGE: Record = { open: { variant: 'in-progress', label: 'Open' }, }; -// ── Component ───────────────────────────────────────────────────────────── +// ── Component ───────────────────────────────────────────────────────────────── export interface PRStatusPanelProps { prNumber: number; @@ -63,15 +73,20 @@ export interface PRStatusPanelProps { } export function PRStatusPanel({ prNumber, workspacePath }: PRStatusPanelProps) { + const [isMerging, setIsMerging] = useState(false); + const [merged, setMerged] = useState(false); + const [mergeError, setMergeError] = useState(null); + const swrKey = `/api/v2/pr/status?workspace_path=${encodeURIComponent(workspacePath)}&pr_number=${prNumber}`; + const proofKey = `/api/v2/proof/status?workspace_path=${encodeURIComponent(workspacePath)}`; const { data, error } = useSWR( swrKey, () => prApi.getStatus(workspacePath, prNumber), { - // Stop polling once the PR is merged or closed. refreshInterval: (latestData) => { if ( + merged || latestData?.merge_state === 'merged' || latestData?.merge_state === 'closed' ) { @@ -82,6 +97,50 @@ export function PRStatusPanel({ prNumber, workspacePath }: PRStatusPanelProps) { } ); + const { data: proofData } = useSWR( + proofKey, + () => proofApi.getStatus(workspacePath), + { refreshInterval: merged ? 0 : 15_000 } + ); + + // ── Gate logic ──────────────────────────────────────────────────────────── + + const openRequirements: ProofRequirement[] = (proofData?.requirements ?? []).filter( + (r) => r.status === 'open' + ); + + const ciFailing = (data?.ci_checks ?? []).some( + (c) => + c.conclusion === 'failure' || + c.conclusion === 'timed_out' || + c.conclusion === 'action_required' + ); + + const ciPending = (data?.ci_checks ?? []).some( + (c) => c.status === 'in_progress' || c.status === 'queued' + ); + + const ciPassing = !ciFailing && !ciPending; + const canMerge = openRequirements.length === 0 && ciPassing; + + // ── Merge handler ───────────────────────────────────────────────────────── + + const handleMerge = async () => { + setIsMerging(true); + setMergeError(null); + try { + await prApi.merge(workspacePath, prNumber, { method: 'squash' }); + setMerged(true); + } catch (err: unknown) { + const apiErr = err as { detail?: string }; + setMergeError(apiErr?.detail ?? 'Merge failed. Please try again.'); + } finally { + setIsMerging(false); + } + }; + + // ── Render ──────────────────────────────────────────────────────────────── + const reviewBadge = REVIEW_BADGE[data?.review_status ?? 'pending'] ?? REVIEW_BADGE.pending; const mergeBadge = MERGE_BADGE[data?.merge_state ?? 'open'] ?? MERGE_BADGE.open; @@ -93,10 +152,7 @@ export function PRStatusPanel({ prNumber, workspacePath }: PRStatusPanelProps) { {!data && !error && (
{[1, 2, 3].map((i) => ( -
+
))}
)} @@ -143,8 +199,91 @@ export function PRStatusPanel({ prNumber, workspacePath }: PRStatusPanelProps) {
)}
+ + {/* PROOF9 gate section */} +
+ PROOF9 + {openRequirements.length === 0 ? ( +

+ + All clear +

+ ) : ( +
+ {openRequirements.map((req) => ( + + {req.title} + + ))} + + View all → + +
+ )} +
)} + + {/* Blocking messages */} + {data && (ciFailing || ciPending) && !merged && ( +

+ {ciFailing ? 'CI checks failing' : 'Waiting for CI checks'} +

+ )} + + {/* Merge error banner */} + {mergeError && ( +
+ {mergeError} +
+ )} + + {/* Success banner or Merge button */} + {merged ? ( +
+ + PR #{prNumber} merged successfully +
+ ) : ( + + + + {/* Wrap in span so tooltip fires even when button is disabled */} + + + + + {!canMerge && ( + + {openRequirements.length > 0 && 'Resolve all open PROOF9 requirements. '} + {ciFailing && 'Fix failing CI checks. '} + {ciPending && 'Wait for CI checks to complete.'} + + )} + + + )} ); } diff --git a/web-ui/src/lib/api.ts b/web-ui/src/lib/api.ts index c0cee6c1..800e5fe3 100644 --- a/web-ui/src/lib/api.ts +++ b/web-ui/src/lib/api.ts @@ -38,6 +38,8 @@ import type { PRResponse, PRStatusResponse, CreatePRRequest, + MergePRRequest, + MergeResponse, ProofRequirement, ProofRequirementListResponse, ProofEvidence, @@ -702,6 +704,19 @@ export const prApi = { }); return response.data; }, + + merge: async ( + workspacePath: string, + prNumber: number, + request: MergePRRequest = {} + ): Promise => { + const response = await api.post( + `/api/v2/pr/${prNumber}/merge`, + { method: request.method ?? 'squash' }, + { params: { workspace_path: workspacePath } } + ); + return response.data; + }, }; // Sessions API methods diff --git a/web-ui/src/types/index.ts b/web-ui/src/types/index.ts index 21dc26e9..d924d61f 100644 --- a/web-ui/src/types/index.ts +++ b/web-ui/src/types/index.ts @@ -272,6 +272,16 @@ export interface CreatePRRequest { base?: string; } +export interface MergePRRequest { + method?: 'squash' | 'merge' | 'rebase'; +} + +export interface MergeResponse { + sha: string | null; + merged: boolean; + message: string; +} + export interface CICheck { name: string; status: 'queued' | 'in_progress' | 'completed' | 'waiting' | 'requested' | 'pending'; From 3c34ff41e596265bacf6df022f645f61e0285630 Mon Sep 17 00:00:00 2001 From: Test User Date: Tue, 14 Apr 2026 09:06:55 -0700 Subject: [PATCH 2/3] fix: address CodeRabbit feedback on PR #583 - Mutate SWR cache on merge success for optimistic UI update - Await final state in in-flight test to prevent act() warning --- web-ui/__tests__/components/review/PRStatusPanel.test.tsx | 4 ++++ web-ui/src/components/review/PRStatusPanel.tsx | 3 ++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/web-ui/__tests__/components/review/PRStatusPanel.test.tsx b/web-ui/__tests__/components/review/PRStatusPanel.test.tsx index 2cccbb83..6e2210b6 100644 --- a/web-ui/__tests__/components/review/PRStatusPanel.test.tsx +++ b/web-ui/__tests__/components/review/PRStatusPanel.test.tsx @@ -167,6 +167,10 @@ describe('PRStatusPanel — PROOF9-gated merge button', () => { }); resolveMerge({ sha: 'abc', merged: true, message: 'ok' }); + + await waitFor(() => { + expect(screen.getByText(/merged successfully/i)).toBeInTheDocument(); + }); }); it('shows CI blocking message when CI checks are failing', () => { diff --git a/web-ui/src/components/review/PRStatusPanel.tsx b/web-ui/src/components/review/PRStatusPanel.tsx index 9308f7f2..85fccf96 100644 --- a/web-ui/src/components/review/PRStatusPanel.tsx +++ b/web-ui/src/components/review/PRStatusPanel.tsx @@ -80,7 +80,7 @@ export function PRStatusPanel({ prNumber, workspacePath }: PRStatusPanelProps) { const swrKey = `/api/v2/pr/status?workspace_path=${encodeURIComponent(workspacePath)}&pr_number=${prNumber}`; const proofKey = `/api/v2/proof/status?workspace_path=${encodeURIComponent(workspacePath)}`; - const { data, error } = useSWR( + const { data, error, mutate: mutatePRStatus } = useSWR( swrKey, () => prApi.getStatus(workspacePath, prNumber), { @@ -131,6 +131,7 @@ export function PRStatusPanel({ prNumber, workspacePath }: PRStatusPanelProps) { try { await prApi.merge(workspacePath, prNumber, { method: 'squash' }); setMerged(true); + mutatePRStatus((prev) => prev ? { ...prev, merge_state: 'merged' } : prev, false); } catch (err: unknown) { const apiErr = err as { detail?: string }; setMergeError(apiErr?.detail ?? 'Merge failed. Please try again.'); From b3c1b88b369832e81d0577275693357ab2dc2291 Mon Sep 17 00:00:00 2001 From: Test User Date: Tue, 14 Apr 2026 09:07:58 -0700 Subject: [PATCH 3/3] fix: guard merge button on data availability (claude-review feedback) Prevent merge button from appearing enabled during initial load by requiring both PR status and proof status data to be present before canMerge can be true. --- web-ui/src/components/review/PRStatusPanel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web-ui/src/components/review/PRStatusPanel.tsx b/web-ui/src/components/review/PRStatusPanel.tsx index 85fccf96..6e43669d 100644 --- a/web-ui/src/components/review/PRStatusPanel.tsx +++ b/web-ui/src/components/review/PRStatusPanel.tsx @@ -121,7 +121,7 @@ export function PRStatusPanel({ prNumber, workspacePath }: PRStatusPanelProps) { ); const ciPassing = !ciFailing && !ciPending; - const canMerge = openRequirements.length === 0 && ciPassing; + const canMerge = !!data && !!proofData && openRequirements.length === 0 && ciPassing; // ── Merge handler ─────────────────────────────────────────────────────────