diff --git a/codeframe/git/github_integration.py b/codeframe/git/github_integration.py index c8c56de0..4c8d5650 100644 --- a/codeframe/git/github_integration.py +++ b/codeframe/git/github_integration.py @@ -32,6 +32,15 @@ def __init__( super().__init__(f"GitHub API Error ({status_code}): {message}") +@dataclass +class CICheck: + """A single CI check run result.""" + + name: str + status: str # "queued" | "in_progress" | "completed" + conclusion: Optional[str] # "success" | "failure" | "neutral" | "cancelled" | etc. + + @dataclass class PRDetails: """Pull Request details from GitHub API.""" @@ -364,6 +373,74 @@ async def close_pull_request(self, pr_number: int) -> bool: logger.info(f"Closed PR #{pr_number}") return data.get("state") == "closed" + async def get_pr_ci_checks( + self, + pr_number: int, + head_sha: Optional[str] = None, + ) -> List[CICheck]: + """Get CI check runs for a pull request. + + Args: + pr_number: PR number + head_sha: Head commit SHA (fetched from the PR if not provided) + + Returns: + List of CICheck results + """ + if head_sha is None: + pr_data = await self._make_request( + "GET", + f"/repos/{self.owner}/{self.repo_name}/pulls/{pr_number}", + ) + head_sha = pr_data["head"]["sha"] + + data = await self._make_request( + "GET", + f"/repos/{self.owner}/{self.repo_name}/commits/{head_sha}/check-runs", + ) + check_runs = data.get("check_runs", []) if isinstance(data, dict) else [] + normalized: list[CICheck] = [] + for run in check_runs: + if not isinstance(run, dict): + continue + name = run.get("name") + status = run.get("status") + if not name or not status: + logger.warning("Skipping malformed check-run entry: %s", run) + continue + normalized.append( + CICheck(name=name, status=status, conclusion=run.get("conclusion")) + ) + return normalized + + async def get_pr_review_status(self, pr_number: int) -> str: + """Get the aggregate review status for a pull request. + + Returns "changes_requested" if any reviewer requested changes, + "approved" if any reviewer approved (and none requested changes), + or "pending" if there are no actionable reviews. + + Args: + pr_number: PR number + + Returns: + "approved" | "changes_requested" | "pending" + """ + reviews = await self._make_request( + "GET", + f"/repos/{self.owner}/{self.repo_name}/pulls/{pr_number}/reviews", + ) + reviews = reviews or [] + + has_changes_requested = any(r.get("state") == "CHANGES_REQUESTED" for r in reviews) + has_approved = any(r.get("state") == "APPROVED" for r in reviews) + + if has_changes_requested: + return "changes_requested" + if has_approved: + return "approved" + return "pending" + async def close(self) -> None: """Close the HTTP client.""" await self._client.aclose() diff --git a/codeframe/ui/routers/pr_v2.py b/codeframe/ui/routers/pr_v2.py index 975c3a09..2c1e0fa6 100644 --- a/codeframe/ui/routers/pr_v2.py +++ b/codeframe/ui/routers/pr_v2.py @@ -11,6 +11,7 @@ POST /api/v2/pr/{number}/close - Close a PR without merging """ +import asyncio import logging from typing import Optional @@ -77,6 +78,24 @@ class MergeResponse(BaseModel): message: str +class CICheckResponse(BaseModel): + """A single CI check run result.""" + + name: str + status: str + conclusion: Optional[str] + + +class PRStatusResponse(BaseModel): + """Live PR status: CI checks, review status, and merge state.""" + + ci_checks: list[CICheckResponse] + review_status: str # "approved" | "changes_requested" | "pending" + merge_state: str # "open" | "merged" | "closed" + pr_url: str + pr_number: int + + # ============================================================================ # Helper Functions # ============================================================================ @@ -124,6 +143,75 @@ def _get_github_client() -> GitHubIntegration: # ============================================================================ +@router.get("/status", response_model=PRStatusResponse) +@rate_limit_standard() +async def get_pr_status( + request: Request, + pr_number: int = Query(..., description="PR number to poll"), + workspace: Workspace = Depends(get_v2_workspace), +) -> PRStatusResponse: + """Get live PR status: CI checks, review status, and merge state. + + Polls the GitHub API for the given PR number and returns a snapshot + of all three status dimensions. The frontend polls this every 30 s + and stops when merge_state is merged or closed. + + Args: + pr_number: PR number to inspect + workspace: v2 Workspace (for context) + + Returns: + PRStatusResponse with CI checks, review status, and merge state + """ + try: + client = _get_github_client() + + # Single call to get PR state, URL, and head SHA. + pr_raw = await client._make_request( + "GET", + f"/repos/{client.owner}/{client.repo_name}/pulls/{pr_number}", + ) + head_sha: str = pr_raw["head"]["sha"] + pr_url: str = pr_raw["html_url"] + merge_state: str = "merged" if pr_raw.get("merged_at") else pr_raw["state"] + + # Fetch CI checks and reviews in parallel (2 more GitHub API calls). + ci_checks, review_status = await asyncio.gather( + client.get_pr_ci_checks(pr_number, head_sha=head_sha), + client.get_pr_review_status(pr_number), + ) + + return PRStatusResponse( + ci_checks=[ + CICheckResponse(name=c.name, status=c.status, conclusion=c.conclusion) + for c in ci_checks + ], + review_status=review_status, + merge_state=merge_state, + pr_url=pr_url, + pr_number=pr_number, + ) + + except GitHubAPIError as e: + if e.status_code == 404: + raise HTTPException( + status_code=404, + detail=api_error("PR not found", ErrorCodes.NOT_FOUND, f"No PR #{pr_number}"), + ) + 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 #{pr_number} status: {e}", exc_info=True) + raise HTTPException( + status_code=500, + detail=api_error("Failed to get PR status", ErrorCodes.EXECUTION_FAILED, str(e)), + ) + + @router.get("", response_model=PRListResponse) @rate_limit_standard() async def list_pull_requests( diff --git a/tests/ui/test_pr_status.py b/tests/ui/test_pr_status.py new file mode 100644 index 00000000..d3c28c6d --- /dev/null +++ b/tests/ui/test_pr_status.py @@ -0,0 +1,231 @@ +"""Tests for GET /api/v2/pr/status endpoint (pr_v2 router). + +These tests verify the PR status endpoint by mocking GitHubIntegration +so no real GitHub API calls are made. +""" + +import shutil +import tempfile +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 CICheck + +# 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_mock_client( + pr_raw: dict | None = None, + ci_checks: list[CICheck] | None = None, + review_status: str = "pending", + raise_error: Exception | None = None, +) -> MagicMock: + """Build a mock GitHubIntegration with configurable responses.""" + client = MagicMock() + + default_pr_raw = { + "head": {"sha": "abc123def456"}, + "html_url": "https://github.com/owner/repo/pull/42", + "state": "open", + "merged_at": None, + } + pr_data = pr_raw if pr_raw is not None else default_pr_raw + + if raise_error: + client._make_request = AsyncMock(side_effect=raise_error) + else: + client._make_request = AsyncMock(return_value=pr_data) + + client.get_pr_ci_checks = AsyncMock( + return_value=ci_checks if ci_checks is not None else [] + ) + client.get_pr_review_status = AsyncMock(return_value=review_status) + client.owner = "owner" + client.repo_name = "repo" + return client + + +# ── Tests ───────────────────────────────────────────────────────────────── + + +class TestGetPrStatusSuccess: + """Happy-path tests for GET /api/v2/pr/status.""" + + def test_returns_200_with_open_pr_no_checks(self, test_client): + """Open PR with no CI checks returns a valid 200 response.""" + mock_client = _make_mock_client(review_status="pending") + + with patch("codeframe.ui.routers.pr_v2._get_github_client", return_value=mock_client): + resp = test_client.get("/api/v2/pr/status?workspace_path=/tmp&pr_number=42") + + assert resp.status_code == 200 + body = resp.json() + assert body["merge_state"] == "open" + assert body["review_status"] == "pending" + assert body["ci_checks"] == [] + assert body["pr_url"] == "https://github.com/owner/repo/pull/42" + assert body["pr_number"] == 42 + + def test_ci_checks_in_response(self, test_client): + """CI checks list populates correctly.""" + checks = [ + CICheck(name="lint", status="completed", conclusion="success"), + CICheck(name="tests", status="in_progress", conclusion=None), + ] + mock_client = _make_mock_client(ci_checks=checks) + + with patch("codeframe.ui.routers.pr_v2._get_github_client", return_value=mock_client): + resp = test_client.get("/api/v2/pr/status?workspace_path=/tmp&pr_number=7") + + assert resp.status_code == 200 + body = resp.json() + assert len(body["ci_checks"]) == 2 + assert body["ci_checks"][0] == { + "name": "lint", + "status": "completed", + "conclusion": "success", + } + assert body["ci_checks"][1] == { + "name": "tests", + "status": "in_progress", + "conclusion": None, + } + + def test_merged_pr(self, test_client): + """PR with merged_at set returns merge_state=merged.""" + pr_raw = { + "head": {"sha": "deadbeef"}, + "html_url": "https://github.com/owner/repo/pull/5", + "state": "closed", + "merged_at": "2026-04-10T12:00:00Z", + } + mock_client = _make_mock_client(pr_raw=pr_raw, review_status="approved") + + with patch("codeframe.ui.routers.pr_v2._get_github_client", return_value=mock_client): + resp = test_client.get("/api/v2/pr/status?workspace_path=/tmp&pr_number=5") + + assert resp.status_code == 200 + assert resp.json()["merge_state"] == "merged" + assert resp.json()["review_status"] == "approved" + + def test_closed_pr(self, test_client): + """PR with state=closed and no merged_at returns merge_state=closed.""" + pr_raw = { + "head": {"sha": "cafebabe"}, + "html_url": "https://github.com/owner/repo/pull/3", + "state": "closed", + "merged_at": None, + } + mock_client = _make_mock_client(pr_raw=pr_raw) + + with patch("codeframe.ui.routers.pr_v2._get_github_client", return_value=mock_client): + resp = test_client.get("/api/v2/pr/status?workspace_path=/tmp&pr_number=3") + + assert resp.status_code == 200 + assert resp.json()["merge_state"] == "closed" + + def test_review_status_changes_requested(self, test_client): + """changes_requested review status propagates to response.""" + mock_client = _make_mock_client(review_status="changes_requested") + + with patch("codeframe.ui.routers.pr_v2._get_github_client", return_value=mock_client): + resp = test_client.get("/api/v2/pr/status?workspace_path=/tmp&pr_number=10") + + assert resp.status_code == 200 + assert resp.json()["review_status"] == "changes_requested" + + def test_head_sha_forwarded_to_ci_checks(self, test_client): + """The head SHA extracted from the PR raw data is passed to get_pr_ci_checks.""" + pr_raw = { + "head": {"sha": "mysha123"}, + "html_url": "https://github.com/owner/repo/pull/1", + "state": "open", + "merged_at": None, + } + mock_client = _make_mock_client(pr_raw=pr_raw) + + with patch("codeframe.ui.routers.pr_v2._get_github_client", return_value=mock_client): + test_client.get("/api/v2/pr/status?workspace_path=/tmp&pr_number=1") + + mock_client.get_pr_ci_checks.assert_awaited_once_with(1, head_sha="mysha123") + + +class TestGetPrStatusErrors: + """Error-handling tests for GET /api/v2/pr/status.""" + + def test_missing_pr_number_returns_422(self, test_client): + """Omitting pr_number returns 422 Unprocessable Entity.""" + resp = test_client.get("/api/v2/pr/status?workspace_path=/tmp") + assert resp.status_code == 422 + + def test_github_not_configured_returns_400(self, test_client): + """When GitHub isn't configured, _get_github_client raises, returning 400.""" + from fastapi import HTTPException + + with patch( + "codeframe.ui.routers.pr_v2._get_github_client", + side_effect=HTTPException(status_code=400, detail="GitHub not configured"), + ): + resp = test_client.get("/api/v2/pr/status?workspace_path=/tmp&pr_number=1") + + assert resp.status_code == 400 + + def test_pr_not_found_returns_404(self, test_client): + """GitHub 404 for the PR propagates as HTTP 404.""" + from codeframe.git.github_integration import GitHubAPIError + + mock_client = _make_mock_client(raise_error=GitHubAPIError(404, "Not Found")) + + with patch("codeframe.ui.routers.pr_v2._get_github_client", return_value=mock_client): + resp = test_client.get("/api/v2/pr/status?workspace_path=/tmp&pr_number=9999") + + assert resp.status_code == 404 + + def test_github_api_error_propagates_status_code(self, test_client): + """GitHub 503 propagates as HTTP 503.""" + 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/status?workspace_path=/tmp&pr_number=42") + + assert resp.status_code == 503 diff --git a/web-ui/src/__tests__/components/review/PRStatusPanel.test.tsx b/web-ui/src/__tests__/components/review/PRStatusPanel.test.tsx new file mode 100644 index 00000000..0db9c2d4 --- /dev/null +++ b/web-ui/src/__tests__/components/review/PRStatusPanel.test.tsx @@ -0,0 +1,217 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import useSWR from 'swr'; +import { PRStatusPanel } from '@/components/review/PRStatusPanel'; +import type { PRStatusResponse } from '@/types'; + +// ── Mocks ───────────────────────────────────────────────────────────────── + +jest.mock('swr'); +jest.mock('@/lib/api', () => ({ + prApi: { getStatus: jest.fn() }, +})); + +const mockUseSWR = useSWR as jest.MockedFunction; + +// ── Helpers ─────────────────────────────────────────────────────────────── + +const WORKSPACE = '/home/user/project'; +const PR_NUMBER = 42; + +const BASE_STATUS: PRStatusResponse = { + ci_checks: [], + review_status: 'pending', + merge_state: 'open', + pr_url: 'https://github.com/owner/repo/pull/42', + pr_number: 42, +}; + +function withData(overrides: Partial = {}) { + mockUseSWR.mockReturnValue({ + data: { ...BASE_STATUS, ...overrides }, + 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 setup(overrides?: Partial) { + withData(overrides); + return render(); +} + +// ── Tests ───────────────────────────────────────────────────────────────── + +describe('PRStatusPanel', () => { + beforeEach(() => jest.clearAllMocks()); + + describe('loading state', () => { + it('renders heading and loading skeleton while data is pending', () => { + withLoading(); + render(); + + expect(screen.getByText('PR Status')).toBeInTheDocument(); + // Badge labels should not be present yet + expect(screen.queryByText('Open')).not.toBeInTheDocument(); + expect(screen.queryByText('Pending Review')).not.toBeInTheDocument(); + }); + }); + + describe('merge state badge', () => { + it('shows Open badge for an open PR', () => { + setup({ merge_state: 'open' }); + expect(screen.getByText('Open')).toBeInTheDocument(); + }); + + it('shows Merged badge when PR is merged', () => { + setup({ merge_state: 'merged' }); + expect(screen.getByText('Merged')).toBeInTheDocument(); + }); + + it('shows Closed badge when PR is closed without merge', () => { + setup({ merge_state: 'closed' }); + expect(screen.getByText('Closed')).toBeInTheDocument(); + }); + }); + + describe('review status badge', () => { + it('shows Pending Review when there are no reviews', () => { + setup({ review_status: 'pending' }); + expect(screen.getByText('Pending Review')).toBeInTheDocument(); + }); + + it('shows Approved badge', () => { + setup({ review_status: 'approved' }); + expect(screen.getByText('Approved')).toBeInTheDocument(); + }); + + it('shows Changes Requested badge', () => { + setup({ review_status: 'changes_requested' }); + expect(screen.getByText('Changes Requested')).toBeInTheDocument(); + }); + }); + + describe('CI checks', () => { + it('shows "No checks found" when ci_checks is empty', () => { + setup({ ci_checks: [] }); + expect(screen.getByText('No checks found.')).toBeInTheDocument(); + }); + + it('renders each check name', () => { + setup({ + ci_checks: [ + { name: 'lint', status: 'completed', conclusion: 'success' }, + { name: 'test-suite', status: 'in_progress', conclusion: null }, + ], + }); + expect(screen.getByText('lint')).toBeInTheDocument(); + expect(screen.getByText('test-suite')).toBeInTheDocument(); + }); + + it('labels an in-progress check as Running', () => { + setup({ + ci_checks: [{ name: 'build', status: 'in_progress', conclusion: null }], + }); + expect(screen.getByText('Running')).toBeInTheDocument(); + }); + + it('labels a queued check as Queued', () => { + setup({ + ci_checks: [{ name: 'deploy', status: 'queued', conclusion: null }], + }); + expect(screen.getByText('Queued')).toBeInTheDocument(); + }); + + it('shows the conclusion value for a completed check', () => { + setup({ + ci_checks: [{ name: 'lint', status: 'completed', conclusion: 'success' }], + }); + expect(screen.getByText('success')).toBeInTheDocument(); + }); + + it('shows the conclusion for a failed check', () => { + setup({ + ci_checks: [{ name: 'test', status: 'completed', conclusion: 'failure' }], + }); + expect(screen.getByText('failure')).toBeInTheDocument(); + }); + }); + + describe('error state', () => { + it('shows fallback message when API call fails and no data cached', () => { + withError(); + render(); + expect(screen.getByText(/Unable to load PR status/i)).toBeInTheDocument(); + }); + }); + + describe('SWR integration', () => { + it('passes the correct SWR key', () => { + withData(); + render(); + + const [key] = mockUseSWR.mock.calls[0]; + expect(key).toContain(`pr_number=${PR_NUMBER}`); + expect(key).toContain(`workspace_path=${WORKSPACE}`); + }); + + it('passes a refreshInterval function to SWR config', () => { + withData(); + render(); + + const config = mockUseSWR.mock.calls[0][2] as { refreshInterval: unknown }; + expect(typeof config.refreshInterval).toBe('function'); + }); + + it('refreshInterval returns 0 when merge_state is merged', () => { + withData({ merge_state: 'merged' }); + render(); + + const config = mockUseSWR.mock.calls[0][2] as { + refreshInterval: (data: PRStatusResponse) => number; + }; + expect(config.refreshInterval({ ...BASE_STATUS, merge_state: 'merged' })).toBe(0); + }); + + it('refreshInterval returns 0 when merge_state is closed', () => { + withData({ merge_state: 'closed' }); + render(); + + const config = mockUseSWR.mock.calls[0][2] as { + refreshInterval: (data: PRStatusResponse) => number; + }; + expect(config.refreshInterval({ ...BASE_STATUS, merge_state: 'closed' })).toBe(0); + }); + + it('refreshInterval returns 30000 when PR is still open', () => { + withData({ merge_state: 'open' }); + render(); + + const config = mockUseSWR.mock.calls[0][2] as { + refreshInterval: (data: PRStatusResponse) => number; + }; + expect(config.refreshInterval({ ...BASE_STATUS, merge_state: 'open' })).toBe(30_000); + }); + }); +}); diff --git a/web-ui/src/app/review/page.tsx b/web-ui/src/app/review/page.tsx index 341e3aef..12eae5a1 100644 --- a/web-ui/src/app/review/page.tsx +++ b/web-ui/src/app/review/page.tsx @@ -21,6 +21,7 @@ import { ReviewHeader } from '@/components/review/ReviewHeader'; import { CommitPanel } from '@/components/review/CommitPanel'; import { ExportPatchModal } from '@/components/review/ExportPatchModal'; import { PRCreatedModal } from '@/components/review/PRCreatedModal'; +import { PRStatusPanel } from '@/components/review/PRStatusPanel'; export default function ReviewPage() { const [workspacePath, setWorkspacePath] = useState(null); @@ -309,18 +310,23 @@ export default function ReviewPage() { changedFiles={diffData?.changed_files ?? []} /> - {/* Commit panel (right sidebar) */} - f.path) ?? []} - onCreatePR={handleCreatePR} - /> + {/* Right sidebar: commit panel + PR status (when a PR has been created) */} +
+ f.path) ?? []} + onCreatePR={handleCreatePR} + /> + {prNumber > 0 && workspacePath && ( + + )} +
{/* Error state */} diff --git a/web-ui/src/components/review/PRStatusPanel.tsx b/web-ui/src/components/review/PRStatusPanel.tsx new file mode 100644 index 00000000..3fed716d --- /dev/null +++ b/web-ui/src/components/review/PRStatusPanel.tsx @@ -0,0 +1,150 @@ +'use client'; + +import useSWR from 'swr'; +import { prApi } from '@/lib/api'; +import { Badge } from '@/components/ui/badge'; +import { Card } from '@/components/ui/card'; +import type { CICheck, PRStatusResponse } from '@/types'; + +// ── Badge variant mappings ──────────────────────────────────────────────── + +type BadgeVariant = + | 'default' + | 'secondary' + | 'destructive' + | 'outline' + | 'ready' + | 'in-progress' + | 'done' + | 'blocked' + | 'failed' + | 'backlog' + | 'merged'; + +function ciCheckVariant(check: CICheck): BadgeVariant { + if (check.status !== 'completed') { + return check.status === 'in_progress' ? 'in-progress' : 'backlog'; + } + switch (check.conclusion) { + case 'success': + return 'done'; + case 'failure': + case 'timed_out': + case 'action_required': + return 'failed'; + default: + return 'backlog'; + } +} + +function ciCheckLabel(check: CICheck): string { + if (check.status === 'in_progress') return 'Running'; + if (check.status === 'queued') return 'Queued'; + return check.conclusion ?? check.status; +} + +const REVIEW_BADGE: Record = { + approved: { variant: 'done', label: 'Approved' }, + changes_requested: { variant: 'failed', label: 'Changes Requested' }, + pending: { variant: 'backlog', label: 'Pending Review' }, +}; + +const MERGE_BADGE: Record = { + merged: { variant: 'merged', label: 'Merged' }, + closed: { variant: 'blocked', label: 'Closed' }, + open: { variant: 'in-progress', label: 'Open' }, +}; + +// ── Component ───────────────────────────────────────────────────────────── + +export interface PRStatusPanelProps { + prNumber: number; + workspacePath: string; +} + +export function PRStatusPanel({ prNumber, workspacePath }: PRStatusPanelProps) { + const swrKey = `/api/v2/pr/status?workspace_path=${workspacePath}&pr_number=${prNumber}`; + + const { data, error } = useSWR( + swrKey, + () => prApi.getStatus(workspacePath, prNumber), + { + // Stop polling once the PR is merged or closed. + refreshInterval: (latestData) => { + if ( + latestData?.merge_state === 'merged' || + latestData?.merge_state === 'closed' + ) { + return 0; + } + return 30_000; + }, + } + ); + + const reviewBadge = REVIEW_BADGE[data?.review_status ?? 'pending'] ?? REVIEW_BADGE.pending; + const mergeBadge = MERGE_BADGE[data?.merge_state ?? 'open'] ?? MERGE_BADGE.open; + + return ( + +

PR Status

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

+ Unable to load PR status — will retry shortly. +

+ )} + + {/* Data */} + {data && ( + <> + {/* Merge state + review status row */} +
+ {mergeBadge.label} + {reviewBadge.label} +
+ + {/* CI checks */} +
+ CI Checks + {data.ci_checks.length === 0 ? ( +

No checks found.

+ ) : ( +
+ {data.ci_checks.map((check, idx) => ( +
+ + {check.name} + + + {ciCheckLabel(check)} + +
+ ))} +
+ )} +
+ + )} + + ); +} diff --git a/web-ui/src/lib/api.ts b/web-ui/src/lib/api.ts index 5c016a63..c0cee6c1 100644 --- a/web-ui/src/lib/api.ts +++ b/web-ui/src/lib/api.ts @@ -36,6 +36,7 @@ import type { GitStatusResponse, CommitResultResponse, PRResponse, + PRStatusResponse, CreatePRRequest, ProofRequirement, ProofRequirementListResponse, @@ -694,6 +695,13 @@ export const prApi = { ); return response.data; }, + + getStatus: async (workspacePath: string, prNumber: number): Promise => { + const response = await api.get('/api/v2/pr/status', { + params: { workspace_path: workspacePath, pr_number: prNumber }, + }); + return response.data; + }, }; // Sessions API methods diff --git a/web-ui/src/types/index.ts b/web-ui/src/types/index.ts index e7f88162..34860c09 100644 --- a/web-ui/src/types/index.ts +++ b/web-ui/src/types/index.ts @@ -272,6 +272,20 @@ export interface CreatePRRequest { base?: string; } +export interface CICheck { + name: string; + status: string; // "queued" | "in_progress" | "completed" + conclusion: string | null; // "success" | "failure" | "neutral" | "cancelled" | etc. +} + +export interface PRStatusResponse { + ci_checks: CICheck[]; + review_status: string; // "approved" | "changes_requested" | "pending" + merge_state: string; // "open" | "merged" | "closed" + pr_url: string; + pr_number: number; +} + // PROOF9 types (mirrors proof_v2.py) export type ProofReqStatus = 'open' | 'satisfied' | 'waived'; export type ProofSeverity = 'critical' | 'high' | 'medium' | 'low';