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..6e2210b6 --- /dev/null +++ b/web-ui/__tests__/components/review/PRStatusPanel.test.tsx @@ -0,0 +1,188 @@ +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' }); + + await waitFor(() => { + expect(screen.getByText(/merged successfully/i)).toBeInTheDocument(); + }); + }); + + 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..6e43669d 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( + const { data, error, mutate: mutatePRStatus } = 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,51 @@ 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 = !!data && !!proofData && openRequirements.length === 0 && ciPassing; + + // ── Merge handler ───────────────────────────────────────────────────────── + + const handleMerge = async () => { + setIsMerging(true); + setMergeError(null); + 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.'); + } 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 +153,7 @@ export function PRStatusPanel({ prNumber, workspacePath }: PRStatusPanelProps) { {!data && !error && (
{[1, 2, 3].map((i) => ( -
+
))}
)} @@ -143,8 +200,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';