diff --git a/codeframe/core/proof/ledger.py b/codeframe/core/proof/ledger.py index 28ce332b..8a348813 100644 --- a/codeframe/core/proof/ledger.py +++ b/codeframe/core/proof/ledger.py @@ -84,6 +84,18 @@ def init_proof_tables(workspace: Workspace) -> None: ) """) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS pr_proof_snapshots ( + pr_number INTEGER NOT NULL, + workspace_id TEXT NOT NULL, + gates_passed INTEGER NOT NULL, + gates_total INTEGER NOT NULL, + gate_breakdown TEXT NOT NULL, + snapshotted_at TEXT NOT NULL, + PRIMARY KEY (pr_number, workspace_id) + ) + """) + conn.commit() conn.close() @@ -101,6 +113,11 @@ def _ensure_tables(workspace: Workspace) -> None: "SELECT name FROM sqlite_master WHERE type='table' AND name='proof_runs'" ) missing = not cursor.fetchone() + if not missing: + cursor.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='pr_proof_snapshots'" + ) + missing = not cursor.fetchone() conn.close() if missing: init_proof_tables(workspace) @@ -493,3 +510,65 @@ def check_expired_waivers(workspace: Workspace) -> list[Requirement]: conn.commit() conn.close() return expired + + +# --- PR Proof Snapshots --- + + +def save_pr_proof_snapshot( + workspace: Workspace, + pr_number: int, + gates_passed: int, + gates_total: int, + gate_breakdown: list[dict], +) -> None: + """Save a proof snapshot for a PR at creation time.""" + _ensure_tables(workspace) + conn = get_db_connection(workspace) + cursor = conn.cursor() + cursor.execute( + """INSERT OR REPLACE INTO pr_proof_snapshots + (pr_number, workspace_id, gates_passed, gates_total, + gate_breakdown, snapshotted_at) + VALUES (?, ?, ?, ?, ?, ?)""", + ( + pr_number, + workspace.id, + gates_passed, + gates_total, + json.dumps(gate_breakdown), + _utc_now().isoformat(), + ), + ) + conn.commit() + conn.close() + + +def get_pr_proof_snapshot( + workspace: Workspace, pr_number: int +) -> Optional[dict]: + """Fetch a proof snapshot for a PR. + + Returns: + Dict with pr_number, gates_passed, gates_total, gate_breakdown, + snapshotted_at — or None if not found. + """ + _ensure_tables(workspace) + conn = get_db_connection(workspace) + cursor = conn.cursor() + cursor.execute( + """SELECT pr_number, gates_passed, gates_total, gate_breakdown, snapshotted_at + FROM pr_proof_snapshots WHERE pr_number = ? AND workspace_id = ?""", + (pr_number, workspace.id), + ) + row = cursor.fetchone() + conn.close() + if not row: + return None + return { + "pr_number": row[0], + "gates_passed": row[1], + "gates_total": row[2], + "gate_breakdown": json.loads(row[3]), + "snapshotted_at": row[4], + } diff --git a/codeframe/git/github_integration.py b/codeframe/git/github_integration.py index 050b1c37..8f619b6a 100644 --- a/codeframe/git/github_integration.py +++ b/codeframe/git/github_integration.py @@ -54,6 +54,7 @@ class PRDetails: merged_at: Optional[datetime] head_branch: str base_branch: str + author: Optional[str] = None @dataclass @@ -220,6 +221,9 @@ def _parse_pr_response(self, data: Dict[str, Any]) -> PRDetails: data["merged_at"].replace("Z", "+00:00") ) + user = data.get("user") + author = user.get("login") if isinstance(user, dict) else None + return PRDetails( number=data["number"], url=data["html_url"], @@ -230,6 +234,7 @@ def _parse_pr_response(self, data: Dict[str, Any]) -> PRDetails: merged_at=merged_at, head_branch=data["head"]["ref"], base_branch=data["base"]["ref"], + author=author, ) async def create_pull_request( diff --git a/codeframe/ui/routers/pr_v2.py b/codeframe/ui/routers/pr_v2.py index c1b1c27a..4ee851b8 100644 --- a/codeframe/ui/routers/pr_v2.py +++ b/codeframe/ui/routers/pr_v2.py @@ -96,6 +96,39 @@ class PRStatusResponse(BaseModel): pr_number: int +class GateBreakdownItem(BaseModel): + """A single gate pass/fail entry in a proof snapshot.""" + + gate: str + status: str + + +class ProofSnapshotOut(BaseModel): + """Proof snapshot at time of PR creation.""" + + gates_passed: int + gates_total: int + gate_breakdown: list[GateBreakdownItem] + + +class PRHistoryItem(BaseModel): + """A single merged PR with optional proof snapshot.""" + + number: int + title: str + merged_at: str + author: Optional[str] + url: str + proof_snapshot: Optional[ProofSnapshotOut] + + +class PRHistoryResponse(BaseModel): + """Response for PR history list.""" + + pull_requests: list[PRHistoryItem] + total: int + + # ============================================================================ # Helper Functions # ============================================================================ @@ -285,6 +318,78 @@ async def list_pull_requests( await client.close() +@router.get("/history", response_model=PRHistoryResponse) +@rate_limit_standard() +async def get_pr_history( + request: Request, + limit: int = Query(10, ge=1, le=50), + workspace: Workspace = Depends(get_v2_workspace), +) -> PRHistoryResponse: + """List recently merged PRs with proof snapshots. + + Returns merged PRs sorted by merged_at descending, each with an + optional proof snapshot showing gate pass/fail at PR creation time. + + Args: + limit: Maximum number of PRs to return (1-50, default 10) + workspace: v2 Workspace + + Returns: + PRHistoryResponse with merged PRs and proof snapshots + """ + from codeframe.core.proof.ledger import get_pr_proof_snapshot + + client = _get_github_client() + try: + prs = await client.list_pull_requests(state="closed") + + # Filter to only merged PRs and sort newest first. + merged = [pr for pr in prs if pr.merged_at is not None] + merged.sort(key=lambda pr: pr.merged_at, reverse=True) + merged = merged[:limit] + + items: list[PRHistoryItem] = [] + for pr in merged: + snapshot = get_pr_proof_snapshot(workspace, pr.number) + proof_snapshot = None + if snapshot: + proof_snapshot = ProofSnapshotOut( + gates_passed=snapshot["gates_passed"], + gates_total=snapshot["gates_total"], + gate_breakdown=[ + GateBreakdownItem(**g) for g in snapshot["gate_breakdown"] + ], + ) + items.append( + PRHistoryItem( + number=pr.number, + title=pr.title, + merged_at=pr.merged_at.isoformat(), + author=pr.author, + url=pr.url, + proof_snapshot=proof_snapshot, + ) + ) + + return PRHistoryResponse(pull_requests=items, total=len(items)) + + except GitHubAPIError as e: + raise HTTPException( + status_code=e.status_code, + detail=api_error("GitHub API error", ErrorCodes.EXECUTION_FAILED, e.message), + ) + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to get PR history: {e}", exc_info=True) + raise HTTPException( + status_code=500, + detail=api_error("Failed to get PR history", ErrorCodes.EXECUTION_FAILED, str(e)), + ) + finally: + await client.close() + + @router.get("/{pr_number}", response_model=PRResponse) @rate_limit_standard() async def get_pull_request( @@ -355,6 +460,41 @@ async def create_pull_request( base=body.base, ) + # Capture proof snapshot at PR creation time. + try: + from codeframe.core.proof.ledger import ( + init_proof_tables, + list_requirements, + save_pr_proof_snapshot, + ) + + init_proof_tables(workspace) + reqs = list_requirements(workspace) + + gates_total = 0 + gates_passed = 0 + gate_breakdown: list[dict] = [] + for req in reqs: + for ob in req.obligations: + gates_total += 1 + passed = ob.status == "satisfied" + if passed: + gates_passed += 1 + gate_breakdown.append({ + "gate": ob.gate.value, + "status": ob.status, + }) + + save_pr_proof_snapshot( + workspace, + pr_number=pr.number, + gates_passed=gates_passed, + gates_total=gates_total, + gate_breakdown=gate_breakdown, + ) + except Exception as snap_err: + logger.warning(f"Failed to save proof snapshot for PR #{pr.number}: {snap_err}") + return _pr_to_response(pr) except GitHubAPIError as e: diff --git a/tests/core/test_proof_snapshot.py b/tests/core/test_proof_snapshot.py new file mode 100644 index 00000000..b33cdcb7 --- /dev/null +++ b/tests/core/test_proof_snapshot.py @@ -0,0 +1,110 @@ +"""Tests for pr_proof_snapshots ledger functions. + +Verifies save_pr_proof_snapshot and get_pr_proof_snapshot work correctly +with the SQLite-backed proof ledger. +""" + +import shutil +import tempfile +from pathlib import Path + +import pytest + +from codeframe.core.workspace import create_or_load_workspace + +pytestmark = pytest.mark.v2 + + +@pytest.fixture +def test_workspace(): + temp_dir = Path(tempfile.mkdtemp()) + workspace_path = temp_dir / "test_ws" + workspace_path.mkdir(parents=True, exist_ok=True) + + workspace = create_or_load_workspace(workspace_path) + + yield workspace + + shutil.rmtree(temp_dir, ignore_errors=True) + + +class TestPrProofSnapshot: + """Tests for save/get pr_proof_snapshot functions.""" + + def test_save_and_get_snapshot(self, test_workspace): + """Save a snapshot, retrieve it, verify all fields.""" + from codeframe.core.proof.ledger import ( + init_proof_tables, + save_pr_proof_snapshot, + get_pr_proof_snapshot, + ) + + init_proof_tables(test_workspace) + + gate_breakdown = [ + {"gate": "unit_test", "status": "satisfied"}, + {"gate": "lint", "status": "failed"}, + ] + save_pr_proof_snapshot( + test_workspace, + pr_number=42, + gates_passed=1, + gates_total=2, + gate_breakdown=gate_breakdown, + ) + + result = get_pr_proof_snapshot(test_workspace, 42) + + assert result is not None + assert result["pr_number"] == 42 + assert result["gates_passed"] == 1 + assert result["gates_total"] == 2 + assert result["gate_breakdown"] == gate_breakdown + assert "snapshotted_at" in result + + def test_get_nonexistent_snapshot_returns_none(self, test_workspace): + """Getting a snapshot for a non-existent PR returns None.""" + from codeframe.core.proof.ledger import ( + init_proof_tables, + get_pr_proof_snapshot, + ) + + init_proof_tables(test_workspace) + + result = get_pr_proof_snapshot(test_workspace, 9999) + assert result is None + + def test_snapshot_overwrites_on_same_pr_number(self, test_workspace): + """Saving a snapshot for the same PR overwrites the previous one.""" + from codeframe.core.proof.ledger import ( + init_proof_tables, + save_pr_proof_snapshot, + get_pr_proof_snapshot, + ) + + init_proof_tables(test_workspace) + + save_pr_proof_snapshot( + test_workspace, + pr_number=10, + gates_passed=3, + gates_total=5, + gate_breakdown=[{"gate": "unit_test", "status": "satisfied"}], + ) + + save_pr_proof_snapshot( + test_workspace, + pr_number=10, + gates_passed=5, + gates_total=5, + gate_breakdown=[ + {"gate": "unit_test", "status": "satisfied"}, + {"gate": "lint", "status": "satisfied"}, + ], + ) + + result = get_pr_proof_snapshot(test_workspace, 10) + assert result is not None + assert result["gates_passed"] == 5 + assert result["gates_total"] == 5 + assert len(result["gate_breakdown"]) == 2 diff --git a/tests/ui/test_pr_history.py b/tests/ui/test_pr_history.py new file mode 100644 index 00000000..52fac8fe --- /dev/null +++ b/tests/ui/test_pr_history.py @@ -0,0 +1,256 @@ +"""Tests for GET /api/v2/pr/history endpoint (pr_v2 router). + +These tests verify the PR history endpoint by mocking GitHubIntegration +so no real GitHub API calls are made. +""" + +import shutil +import tempfile +from datetime import datetime, timezone +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from codeframe.git.github_integration import PRDetails + +# Mark all tests as v2 +pytestmark = pytest.mark.v2 + + +# -- Fixtures ---------------------------------------------------------------- + + +@pytest.fixture +def test_workspace(): + temp_dir = Path(tempfile.mkdtemp()) + workspace_path = temp_dir / "test_ws" + workspace_path.mkdir(parents=True, exist_ok=True) + + from codeframe.core.workspace import create_or_load_workspace + + workspace = create_or_load_workspace(workspace_path) + + yield workspace + + shutil.rmtree(temp_dir, ignore_errors=True) + + +@pytest.fixture +def test_client(test_workspace): + from codeframe.ui.routers import pr_v2 + from codeframe.ui.dependencies import get_v2_workspace + + app = FastAPI() + app.include_router(pr_v2.router) + + def get_test_workspace(): + return test_workspace + + app.dependency_overrides[get_v2_workspace] = get_test_workspace + return TestClient(app, raise_server_exceptions=False) + + +def _make_pr( + number: int, + title: str = "Test PR", + state: str = "closed", + merged_at: datetime | None = None, + author: str | None = None, +) -> PRDetails: + """Build a PRDetails with sensible defaults.""" + return PRDetails( + number=number, + url=f"https://github.com/owner/repo/pull/{number}", + state=state, + title=title, + body="body", + created_at=datetime(2026, 1, 1, tzinfo=timezone.utc), + merged_at=merged_at, + head_branch="feature", + base_branch="main", + author=author, + ) + + +def _make_mock_client( + prs: list[PRDetails] | None = None, + raise_error: Exception | None = None, +) -> MagicMock: + """Build a mock GitHubIntegration with configurable responses.""" + client = MagicMock() + + if raise_error: + client.list_pull_requests = AsyncMock(side_effect=raise_error) + else: + client.list_pull_requests = AsyncMock(return_value=prs or []) + + client.close = AsyncMock() + client.owner = "owner" + client.repo_name = "repo" + return client + + +# -- Tests ------------------------------------------------------------------- + + +class TestGetPrHistory: + """Tests for GET /api/v2/pr/history.""" + + def test_returns_merged_prs_with_proof_snapshot(self, test_client, test_workspace): + """Merged PR with a saved proof snapshot appears in response.""" + from codeframe.core.proof.ledger import ( + init_proof_tables, + save_pr_proof_snapshot, + ) + + init_proof_tables(test_workspace) + + merged_pr = _make_pr( + number=10, + title="Merged PR", + merged_at=datetime(2026, 4, 10, 12, 0, 0, tzinfo=timezone.utc), + author="alice", + ) + unmerged_pr = _make_pr(number=11, title="Closed but not merged") + + save_pr_proof_snapshot( + test_workspace, + pr_number=10, + gates_passed=7, + gates_total=9, + gate_breakdown=[ + {"gate": "unit_test", "status": "satisfied"}, + {"gate": "lint", "status": "failed"}, + ], + ) + + mock_client = _make_mock_client(prs=[merged_pr, unmerged_pr]) + + with patch("codeframe.ui.routers.pr_v2._get_github_client", return_value=mock_client): + resp = test_client.get("/api/v2/pr/history?workspace_path=/tmp") + + assert resp.status_code == 200 + body = resp.json() + assert body["total"] == 1 + assert len(body["pull_requests"]) == 1 + + pr_item = body["pull_requests"][0] + assert pr_item["number"] == 10 + assert pr_item["title"] == "Merged PR" + assert pr_item["author"] == "alice" + assert pr_item["proof_snapshot"] is not None + assert pr_item["proof_snapshot"]["gates_passed"] == 7 + assert pr_item["proof_snapshot"]["gates_total"] == 9 + assert len(pr_item["proof_snapshot"]["gate_breakdown"]) == 2 + + def test_returns_empty_when_no_merged_prs(self, test_client): + """Closed PRs with no merged_at yield empty list.""" + unmerged = _make_pr(number=1) + mock_client = _make_mock_client(prs=[unmerged]) + + with patch("codeframe.ui.routers.pr_v2._get_github_client", return_value=mock_client): + resp = test_client.get("/api/v2/pr/history?workspace_path=/tmp") + + assert resp.status_code == 200 + body = resp.json() + assert body["total"] == 0 + assert body["pull_requests"] == [] + + def test_limit_parameter(self, test_client): + """Limit parameter restricts the number of returned PRs.""" + prs = [ + _make_pr( + number=i, + merged_at=datetime(2026, 4, i, tzinfo=timezone.utc), + ) + for i in range(1, 4) + ] + mock_client = _make_mock_client(prs=prs) + + with patch("codeframe.ui.routers.pr_v2._get_github_client", return_value=mock_client): + resp = test_client.get("/api/v2/pr/history?workspace_path=/tmp&limit=2") + + assert resp.status_code == 200 + assert len(resp.json()["pull_requests"]) == 2 + + def test_pr_without_snapshot_has_null(self, test_client, test_workspace): + """Merged PR with no saved snapshot has proof_snapshot=null.""" + from codeframe.core.proof.ledger import init_proof_tables + + init_proof_tables(test_workspace) + + merged_pr = _make_pr( + number=5, + merged_at=datetime(2026, 4, 5, tzinfo=timezone.utc), + ) + mock_client = _make_mock_client(prs=[merged_pr]) + + with patch("codeframe.ui.routers.pr_v2._get_github_client", return_value=mock_client): + resp = test_client.get("/api/v2/pr/history?workspace_path=/tmp") + + assert resp.status_code == 200 + pr_item = resp.json()["pull_requests"][0] + assert pr_item["proof_snapshot"] is None + + def test_sorted_by_merged_at_descending(self, test_client): + """PRs are returned newest-first by merged_at.""" + old_pr = _make_pr( + number=1, + title="Old PR", + merged_at=datetime(2026, 1, 1, tzinfo=timezone.utc), + ) + new_pr = _make_pr( + number=2, + title="New PR", + merged_at=datetime(2026, 4, 10, tzinfo=timezone.utc), + ) + # Return in wrong order — endpoint should re-sort. + mock_client = _make_mock_client(prs=[old_pr, new_pr]) + + with patch("codeframe.ui.routers.pr_v2._get_github_client", return_value=mock_client): + resp = test_client.get("/api/v2/pr/history?workspace_path=/tmp") + + assert resp.status_code == 200 + items = resp.json()["pull_requests"] + assert items[0]["number"] == 2 + assert items[1]["number"] == 1 + + def test_github_error_returns_error(self, test_client): + """GitHubAPIError propagates as appropriate HTTP error.""" + from codeframe.git.github_integration import GitHubAPIError + + mock_client = _make_mock_client( + raise_error=GitHubAPIError(503, "Service Unavailable"), + ) + + with patch("codeframe.ui.routers.pr_v2._get_github_client", return_value=mock_client): + resp = test_client.get("/api/v2/pr/history?workspace_path=/tmp") + + assert resp.status_code == 503 + + def test_client_close_called(self, test_client): + """Client.close() is always called.""" + mock_client = _make_mock_client(prs=[]) + + with patch("codeframe.ui.routers.pr_v2._get_github_client", return_value=mock_client): + test_client.get("/api/v2/pr/history?workspace_path=/tmp") + + mock_client.close.assert_awaited_once() + + def test_author_included(self, test_client): + """PR author field appears in the response.""" + pr = _make_pr( + number=7, + merged_at=datetime(2026, 4, 7, tzinfo=timezone.utc), + author="bob", + ) + mock_client = _make_mock_client(prs=[pr]) + + with patch("codeframe.ui.routers.pr_v2._get_github_client", return_value=mock_client): + resp = test_client.get("/api/v2/pr/history?workspace_path=/tmp") + + assert resp.status_code == 200 + assert resp.json()["pull_requests"][0]["author"] == "bob" diff --git a/web-ui/__mocks__/@hugeicons/react.js b/web-ui/__mocks__/@hugeicons/react.js index 6f998146..56533835 100644 --- a/web-ui/__mocks__/@hugeicons/react.js +++ b/web-ui/__mocks__/@hugeicons/react.js @@ -69,4 +69,6 @@ module.exports = { // BlockerCard origin badges Settings01Icon: createIconMock('Settings01Icon'), UserCircle02Icon: createIconMock('UserCircle02Icon'), + // PRHistoryPanel + ArrowUpRight01Icon: createIconMock('ArrowUpRight01Icon'), }; diff --git a/web-ui/src/__tests__/components/review/PRHistoryPanel.test.tsx b/web-ui/src/__tests__/components/review/PRHistoryPanel.test.tsx new file mode 100644 index 00000000..cc8f26b1 --- /dev/null +++ b/web-ui/src/__tests__/components/review/PRHistoryPanel.test.tsx @@ -0,0 +1,236 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import useSWR from 'swr'; +import { PRHistoryPanel } from '@/components/review/PRHistoryPanel'; +import type { PRHistoryResponse } from '@/types'; + +// ── Mocks ───────────────────────────────────────────────────────────────── + +jest.mock('swr'); +jest.mock('@/lib/api', () => ({ + prApi: { getHistory: jest.fn() }, +})); + +const mockUseSWR = useSWR as jest.MockedFunction; + +// ── Helpers ─────────────────────────────────────────────────────────────── + +const WORKSPACE = '/home/user/project'; + +const SAMPLE_HISTORY: PRHistoryResponse = { + pull_requests: [ + { + number: 10, + title: 'feat: add user auth', + merged_at: '2026-04-10T12:00:00Z', + author: 'alice', + url: 'https://github.com/owner/repo/pull/10', + proof_snapshot: { + gates_passed: 7, + gates_total: 9, + gate_breakdown: [ + { gate: 'unit-tests', status: 'satisfied' }, + { gate: 'lint', status: 'satisfied' }, + { gate: 'security', status: 'failed' }, + ], + }, + }, + { + number: 11, + title: 'fix: resolve login bug', + merged_at: '2026-04-11T14:00:00Z', + author: null, + url: 'https://github.com/owner/repo/pull/11', + proof_snapshot: null, + }, + ], + total: 2, +}; + +const ALL_PASSED: PRHistoryResponse = { + pull_requests: [ + { + number: 20, + title: 'feat: perfect PR', + merged_at: '2026-04-12T10:00:00Z', + author: 'bob', + url: 'https://github.com/owner/repo/pull/20', + proof_snapshot: { + gates_passed: 9, + gates_total: 9, + gate_breakdown: [ + { gate: 'unit-tests', status: 'satisfied' }, + { gate: 'lint', status: 'satisfied' }, + ], + }, + }, + ], + total: 1, +}; + +function withData(data: PRHistoryResponse) { + mockUseSWR.mockReturnValue({ + data, + error: undefined, + isLoading: false, + isValidating: false, + mutate: jest.fn(), + } as ReturnType); +} + +function withLoading() { + mockUseSWR.mockReturnValue({ + data: undefined, + error: undefined, + isLoading: true, + isValidating: true, + mutate: jest.fn(), + } as ReturnType); +} + +function withError() { + mockUseSWR.mockReturnValue({ + data: undefined, + error: new Error('Network error'), + isLoading: false, + isValidating: false, + mutate: jest.fn(), + } as ReturnType); +} + +function withEmpty() { + withData({ pull_requests: [], total: 0 }); +} + +// ── Tests ───────────────────────────────────────────────────────────────── + +describe('PRHistoryPanel', () => { + beforeEach(() => jest.clearAllMocks()); + + describe('loading state', () => { + it('renders heading and loading skeleton while data is pending', () => { + withLoading(); + render(); + + expect(screen.getByText('PR History')).toBeInTheDocument(); + // PR titles should not be present yet + expect(screen.queryByText('feat: add user auth')).not.toBeInTheDocument(); + }); + }); + + describe('empty state', () => { + it('renders "No merged PRs yet" when list is empty', () => { + withEmpty(); + render(); + + expect(screen.getByText('No merged PRs yet')).toBeInTheDocument(); + }); + }); + + describe('error state', () => { + it('shows fallback message when API call fails', () => { + withError(); + render(); + + expect(screen.getByText('Unable to load PR history')).toBeInTheDocument(); + }); + }); + + describe('PR list rendering', () => { + it('renders PR titles', () => { + withData(SAMPLE_HISTORY); + render(); + + expect(screen.getByText('feat: add user auth')).toBeInTheDocument(); + expect(screen.getByText('fix: resolve login bug')).toBeInTheDocument(); + }); + + it('renders merge dates and author', () => { + withData(SAMPLE_HISTORY); + render(); + + // Check that date text is present (formatted by toLocaleDateString) + const dateText = new Date('2026-04-10T12:00:00Z').toLocaleDateString(); + expect(screen.getByText(new RegExp(dateText))).toBeInTheDocument(); + expect(screen.getByText(/by alice/)).toBeInTheDocument(); + }); + + it('renders proof badge with partial pass count', () => { + withData(SAMPLE_HISTORY); + render(); + + expect(screen.getByText('7/9 gates')).toBeInTheDocument(); + }); + + it('renders "No proof data" badge when snapshot is null', () => { + withData(SAMPLE_HISTORY); + render(); + + expect(screen.getByText('No proof data')).toBeInTheDocument(); + }); + + it('renders proof badge with all-pass styling text', () => { + withData(ALL_PASSED); + render(); + + expect(screen.getByText('9/9 gates')).toBeInTheDocument(); + }); + }); + + describe('expand/collapse gate breakdown', () => { + it('clicking a row shows gate breakdown', () => { + withData(SAMPLE_HISTORY); + render(); + + // Gate names should not be visible initially + expect(screen.queryByText('unit-tests')).not.toBeInTheDocument(); + + // Click the first PR row + fireEvent.click(screen.getByText('feat: add user auth')); + + // Gate breakdown should now be visible + expect(screen.getByText('unit-tests')).toBeInTheDocument(); + expect(screen.getByText('lint')).toBeInTheDocument(); + expect(screen.getByText('security')).toBeInTheDocument(); + }); + + it('clicking the same row again collapses gate breakdown', () => { + withData(SAMPLE_HISTORY); + render(); + + // Expand + fireEvent.click(screen.getByText('feat: add user auth')); + expect(screen.getByText('unit-tests')).toBeInTheDocument(); + + // Collapse + fireEvent.click(screen.getByText('feat: add user auth')); + expect(screen.queryByText('unit-tests')).not.toBeInTheDocument(); + }); + + it('shows "No proof snapshot available" when expanding a PR without proof data', () => { + withData(SAMPLE_HISTORY); + render(); + + fireEvent.click(screen.getByText('fix: resolve login bug')); + expect(screen.getByText('No proof snapshot available for this PR.')).toBeInTheDocument(); + }); + }); + + describe('SWR integration', () => { + it('passes the correct SWR key containing workspace path', () => { + withData(SAMPLE_HISTORY); + render(); + + const [key] = mockUseSWR.mock.calls[0]; + expect(key).toContain(`workspace_path=${encodeURIComponent(WORKSPACE)}`); + }); + + it('passes null SWR key when workspacePath is empty', () => { + withLoading(); + render(); + + const [key] = mockUseSWR.mock.calls[0]; + expect(key).toBeNull(); + }); + }); +}); diff --git a/web-ui/src/app/review/page.tsx b/web-ui/src/app/review/page.tsx index 12eae5a1..64197ef0 100644 --- a/web-ui/src/app/review/page.tsx +++ b/web-ui/src/app/review/page.tsx @@ -22,6 +22,7 @@ import { CommitPanel } from '@/components/review/CommitPanel'; import { ExportPatchModal } from '@/components/review/ExportPatchModal'; import { PRCreatedModal } from '@/components/review/PRCreatedModal'; import { PRStatusPanel } from '@/components/review/PRStatusPanel'; +import { PRHistoryPanel } from '@/components/review/PRHistoryPanel'; export default function ReviewPage() { const [workspacePath, setWorkspacePath] = useState(null); @@ -329,6 +330,13 @@ export default function ReviewPage() { + {/* PR History */} + {workspacePath && ( +
+ +
+ )} + {/* Error state */} {diffError && (
diff --git a/web-ui/src/components/review/PRHistoryPanel.tsx b/web-ui/src/components/review/PRHistoryPanel.tsx new file mode 100644 index 00000000..05bb5609 --- /dev/null +++ b/web-ui/src/components/review/PRHistoryPanel.tsx @@ -0,0 +1,171 @@ +'use client'; + +import { useState } from 'react'; +import useSWR from 'swr'; +import { + ArrowDown01Icon, + ArrowUp01Icon, + ArrowUpRight01Icon, + CheckmarkCircle01Icon, + Cancel01Icon, +} from '@hugeicons/react'; +import { prApi } from '@/lib/api'; +import { Badge } from '@/components/ui/badge'; +import { Card } from '@/components/ui/card'; +import type { + PRHistoryResponse, + PRHistoryItem, + ProofSnapshot, + GateBreakdownItem, +} from '@/types'; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function proofBadgeClasses(snapshot: ProofSnapshot | null): string { + if (!snapshot) return 'text-muted-foreground bg-muted'; + if (snapshot.gates_total > 0 && snapshot.gates_passed === snapshot.gates_total) { + return 'text-green-600 bg-green-50'; + } + return 'text-yellow-600 bg-yellow-50'; +} + +function proofBadgeText(snapshot: ProofSnapshot | null): string { + if (!snapshot) return 'No proof data'; + return `${snapshot.gates_passed}/${snapshot.gates_total} gates`; +} + +// ── Component ──────────────────────────────────────────────────────────────── + +export interface PRHistoryPanelProps { + workspacePath: string; +} + +export function PRHistoryPanel({ workspacePath }: PRHistoryPanelProps) { + const [expandedPR, setExpandedPR] = useState(null); + + const swrKey = workspacePath + ? `/api/v2/pr/history?workspace_path=${encodeURIComponent(workspacePath)}` + : null; + + const { data, error } = useSWR( + swrKey, + () => prApi.getHistory(workspacePath) + ); + + const toggleExpand = (prNumber: number) => { + setExpandedPR((prev) => (prev === prNumber ? null : prNumber)); + }; + + return ( + +

PR History

+ + {/* Loading skeleton */} + {!data && !error && ( +
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+ )} + + {/* Error state */} + {error && !data && ( +

+ Unable to load PR history +

+ )} + + {/* Empty state */} + {data && data.pull_requests.length === 0 && ( +

No merged PRs yet

+ )} + + {/* PR list */} + {data && data.pull_requests.length > 0 && ( +
+ {data.pull_requests.map((pr: PRHistoryItem) => ( +
+ {/* Row header */} +
+ + + + +
+ + {/* Expanded gate breakdown */} + {expandedPR === pr.number && pr.proof_snapshot && ( +
+
+ {pr.proof_snapshot.gate_breakdown.map((gate: GateBreakdownItem) => ( +
+ {gate.status === 'satisfied' ? ( + + ) : ( + + )} + + {gate.gate} + +
+ ))} +
+
+ )} + + {/* Expanded but no proof data */} + {expandedPR === pr.number && !pr.proof_snapshot && ( +
+

+ No proof snapshot available for this PR. +

+
+ )} +
+ ))} +
+ )} + + ); +} diff --git a/web-ui/src/lib/api.ts b/web-ui/src/lib/api.ts index 800e5fe3..1a417ac8 100644 --- a/web-ui/src/lib/api.ts +++ b/web-ui/src/lib/api.ts @@ -52,6 +52,7 @@ import type { RunStatusResponse, ProofRunSummary, ProofRunDetail, + PRHistoryResponse, Session, SessionState, SessionListResponse, @@ -717,6 +718,13 @@ export const prApi = { ); return response.data; }, + + getHistory: async (workspacePath: string, limit?: number): Promise => { + const response = await api.get('/api/v2/pr/history', { + params: { workspace_path: workspacePath, ...(limit ? { limit } : {}) }, + }); + return response.data; + }, }; // Sessions API methods diff --git a/web-ui/src/types/index.ts b/web-ui/src/types/index.ts index d924d61f..9f20ebe0 100644 --- a/web-ui/src/types/index.ts +++ b/web-ui/src/types/index.ts @@ -432,6 +432,32 @@ export interface GateRunEntry { } +// PR History types (mirrors pr_v2.py PRHistoryResponse) +export interface GateBreakdownItem { + gate: string; + status: string; +} + +export interface ProofSnapshot { + gates_passed: number; + gates_total: number; + gate_breakdown: GateBreakdownItem[]; +} + +export interface PRHistoryItem { + number: number; + title: string; + merged_at: string; + author: string | null; + url: string; + proof_snapshot: ProofSnapshot | null; +} + +export interface PRHistoryResponse { + pull_requests: PRHistoryItem[]; + total: number; +} + // Quick Actions props (dashboard) export interface QuickActionsProps { taskCounts?: TaskStatusCounts;