Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
188 changes: 188 additions & 0 deletions web-ui/__tests__/components/review/PRStatusPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof useSWR>;
const mockMerge = prApi.merge as jest.MockedFunction<typeof prApi.merge>;

// ── 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(<PRStatusPanel {...defaultProps} />);
expect(screen.getByRole('button', { name: /^merge$/i })).toBeDisabled();
});

it('shows blocking REQ titles inline when PROOF9 has open requirements', () => {
setupSWRMock(basePRStatus, proofStatusWithOpenReqs);
render(<PRStatusPanel {...defaultProps} />);
expect(screen.getByText('Fix critical bug')).toBeInTheDocument();
});

it('shows link to /proof page when PROOF9 is blocking', () => {
setupSWRMock(basePRStatus, proofStatusWithOpenReqs);
render(<PRStatusPanel {...defaultProps} />);
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(<PRStatusPanel {...defaultProps} />);
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(<PRStatusPanel {...defaultProps} />);

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(<PRStatusPanel {...defaultProps} />);

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(<PRStatusPanel {...defaultProps} />);

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(<PRStatusPanel {...defaultProps} />);
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(<PRStatusPanel {...defaultProps} />);
expect(screen.getByText(/ci checks failing/i)).toBeInTheDocument();
expect(screen.getByText('Fix critical bug')).toBeInTheDocument();
});
});
158 changes: 149 additions & 9 deletions web-ui/src/components/review/PRStatusPanel.tsx
Original file line number Diff line number Diff line change
@@ -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 ────────────────────────────────────────────────

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

// ── Component ─────────────────────────────────────────────────────────────
// ── Component ─────────────────────────────────────────────────────────────────

export interface PRStatusPanelProps {
prNumber: number;
workspacePath: string;
}

export function PRStatusPanel({ prNumber, workspacePath }: PRStatusPanelProps) {
const [isMerging, setIsMerging] = useState(false);
const [merged, setMerged] = useState(false);
const [mergeError, setMergeError] = useState<string | null>(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<PRStatusResponse>(
const { data, error, mutate: mutatePRStatus } = useSWR<PRStatusResponse>(
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'
) {
Expand All @@ -82,6 +97,51 @@ export function PRStatusPanel({ prNumber, workspacePath }: PRStatusPanelProps) {
}
);

const { data: proofData } = useSWR<ProofStatusResponse>(
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;

Expand All @@ -93,10 +153,7 @@ export function PRStatusPanel({ prNumber, workspacePath }: PRStatusPanelProps) {
{!data && !error && (
<div className="flex flex-col gap-2">
{[1, 2, 3].map((i) => (
<div
key={i}
className="h-5 animate-pulse rounded bg-muted"
/>
<div key={i} className="h-5 animate-pulse rounded bg-muted" />
))}
</div>
)}
Expand Down Expand Up @@ -143,8 +200,91 @@ export function PRStatusPanel({ prNumber, workspacePath }: PRStatusPanelProps) {
</div>
)}
</div>

{/* PROOF9 gate section */}
<div className="flex flex-col gap-1.5">
<span className="text-sm font-medium">PROOF9</span>
{openRequirements.length === 0 ? (
<p className="flex items-center gap-1 text-xs text-muted-foreground">
<CheckmarkCircle01Icon className="h-3 w-3 text-green-600" />
All clear
</p>
) : (
<div className="flex flex-col gap-1">
{openRequirements.map((req) => (
<Link
key={req.id}
href={`/proof/${req.id}`}
className="text-xs text-red-600 hover:underline"
>
{req.title}
</Link>
))}
<Link
href="/proof"
className="mt-1 text-xs text-muted-foreground hover:underline"
>
View all →
</Link>
</div>
)}
</div>
</>
)}

{/* Blocking messages */}
{data && (ciFailing || ciPending) && !merged && (
<p className="text-xs text-amber-600">
{ciFailing ? 'CI checks failing' : 'Waiting for CI checks'}
</p>
)}

{/* Merge error banner */}
{mergeError && (
<div className="rounded bg-red-50 px-3 py-2 text-xs text-red-700">
{mergeError}
</div>
)}

{/* Success banner or Merge button */}
{merged ? (
<div className="flex items-center gap-1 rounded bg-green-50 px-3 py-2 text-xs text-green-700">
<CheckmarkCircle01Icon className="h-3 w-3" />
PR #{prNumber} merged successfully
</div>
) : (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
{/* Wrap in span so tooltip fires even when button is disabled */}
<span className="w-full">
<Button
onClick={handleMerge}
disabled={!canMerge || isMerging}
size="sm"
className="w-full transition-all"
>
{isMerging ? (
<>
<Loading03Icon className="mr-1.5 h-4 w-4 animate-spin" />
Merging...
</>
) : (
'Merge'
)}
</Button>
</span>
</TooltipTrigger>
{!canMerge && (
<TooltipContent>
{openRequirements.length > 0 && 'Resolve all open PROOF9 requirements. '}
{ciFailing && 'Fix failing CI checks. '}
{ciPending && 'Wait for CI checks to complete.'}
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
)}
</Card>
);
}
Loading
Loading