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
30 changes: 30 additions & 0 deletions codeframe/git/github_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,36 @@ 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_files(self, pr_number: int) -> List[str]:
"""Get the list of files changed in a pull request.

Paginates through all pages (100 per page) to ensure completeness.

Args:
pr_number: PR number

Returns:
List of filenames changed in the PR

Raises:
GitHubAPIError: If API error occurs
"""
files: List[str] = []
page = 1
while True:
endpoint = (
f"/repos/{self.owner}/{self.repo_name}/pulls/{pr_number}/files"
f"?per_page=100&page={page}"
)
data = await self._make_request(method="GET", endpoint=endpoint)
if not isinstance(data, list) or not data:
break
files.extend(f["filename"] for f in data)
if len(data) < 100:
break
page += 1
return files

async def get_pr_ci_checks(
self,
pr_number: int,
Expand Down
48 changes: 48 additions & 0 deletions codeframe/ui/routers/pr_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,12 @@ class PRHistoryItem(BaseModel):
proof_snapshot: Optional[ProofSnapshotOut]


class PRFilesResponse(BaseModel):
"""Response for PR changed files."""

files: list[str]


class PRHistoryResponse(BaseModel):
"""Response for PR history list."""

Expand Down Expand Up @@ -390,6 +396,48 @@ async def get_pr_history(
await client.close()


@router.get("/{pr_number}/files", response_model=PRFilesResponse)
@rate_limit_standard()
async def get_pr_files(
request: Request,
pr_number: int,
workspace: Workspace = Depends(get_v2_workspace),
) -> PRFilesResponse:
"""Get the list of files changed in a pull request.

Args:
pr_number: PR number
workspace: v2 Workspace (for context)

Returns:
List of changed filenames
"""
client = _get_github_client()
try:
files = await client.get_pr_files(pr_number)
return PRFilesResponse(files=files)
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 files for PR #{pr_number}: {e}", exc_info=True)
raise HTTPException(
status_code=500,
detail=api_error("Failed to get PR files", ErrorCodes.EXECUTION_FAILED, str(e)),
)
finally:
await client.close()


@router.get("/{pr_number}", response_model=PRResponse)
@rate_limit_standard()
async def get_pull_request(
Expand Down
57 changes: 54 additions & 3 deletions tests/ui/test_pr_history.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Tests for GET /api/v2/pr/history endpoint (pr_v2 router).
"""Tests for PR v2 router endpoints: history and files.

These tests verify the PR history endpoint by mocking GitHubIntegration
so no real GitHub API calls are made.
These tests verify the PR history and PR files endpoints by mocking
GitHubIntegration so no real GitHub API calls are made.
"""

import shutil
Expand Down Expand Up @@ -254,3 +254,54 @@ def test_author_included(self, test_client):

assert resp.status_code == 200
assert resp.json()["pull_requests"][0]["author"] == "bob"


class TestGetPrFiles:
"""Tests for GET /api/v2/pr/{pr_number}/files."""

def test_returns_file_list(self, test_client):
"""Endpoint returns the list of changed files for a PR."""
mock_client = _make_mock_client()
mock_client.get_pr_files = AsyncMock(return_value=["src/app.py", "tests/test_app.py"])

with patch("codeframe.ui.routers.pr_v2._get_github_client", return_value=mock_client):
resp = test_client.get("/api/v2/pr/42/files?workspace_path=/tmp")

assert resp.status_code == 200
body = resp.json()
assert body["files"] == ["src/app.py", "tests/test_app.py"]

def test_returns_empty_list(self, test_client):
"""Endpoint returns empty list when PR has no file changes."""
mock_client = _make_mock_client()
mock_client.get_pr_files = AsyncMock(return_value=[])

with patch("codeframe.ui.routers.pr_v2._get_github_client", return_value=mock_client):
resp = test_client.get("/api/v2/pr/1/files?workspace_path=/tmp")

assert resp.status_code == 200
assert resp.json()["files"] == []

def test_pr_not_found(self, test_client):
"""Endpoint returns 404 when PR does not exist."""
from codeframe.git.github_integration import GitHubAPIError

mock_client = _make_mock_client()
mock_client.get_pr_files = AsyncMock(
side_effect=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/99999/files?workspace_path=/tmp")

assert resp.status_code == 404

def test_client_close_called(self, test_client):
"""Client.close() is always called."""
mock_client = _make_mock_client()
mock_client.get_pr_files = AsyncMock(return_value=[])

with patch("codeframe.ui.routers.pr_v2._get_github_client", return_value=mock_client):
test_client.get("/api/v2/pr/1/files?workspace_path=/tmp")

mock_client.close.assert_awaited_once()
49 changes: 49 additions & 0 deletions tests/unit/test_github_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,55 @@ async def test_rate_limit_error(self, github):
assert exc_info.value.status_code == 403


@pytest.mark.v2
class TestGetPrFiles:
"""Tests for GitHubIntegration.get_pr_files."""

@pytest.fixture
def github(self):
return GitHubIntegration(
token="ghp_test_token_12345",
repo="owner/test-repo",
)

@pytest.mark.asyncio
async def test_returns_list_of_filenames(self, github):
"""get_pr_files returns a list of filename strings."""
mock_response = [
{"filename": "src/app.py", "status": "modified"},
{"filename": "tests/test_app.py", "status": "added"},
]

with patch.object(github, "_make_request", new_callable=AsyncMock) as mock_request:
mock_request.return_value = mock_response
files = await github.get_pr_files(42)

assert files == ["src/app.py", "tests/test_app.py"]
mock_request.assert_called_once()
call_kwargs = mock_request.call_args.kwargs
assert call_kwargs["method"] == "GET"
assert "/pulls/42/files" in call_kwargs["endpoint"]
assert "per_page=100" in call_kwargs["endpoint"]

@pytest.mark.asyncio
async def test_returns_empty_list_for_no_files(self, github):
"""get_pr_files returns empty list when PR has no file changes."""
with patch.object(github, "_make_request", new_callable=AsyncMock) as mock_request:
mock_request.return_value = []
files = await github.get_pr_files(1)

assert files == []

@pytest.mark.asyncio
async def test_propagates_api_error(self, github):
"""get_pr_files propagates GitHubAPIError."""
with patch.object(github, "_make_request", new_callable=AsyncMock) as mock_request:
mock_request.side_effect = GitHubAPIError(404, "Not Found")
with pytest.raises(GitHubAPIError) as exc_info:
await github.get_pr_files(99999)
assert exc_info.value.status_code == 404

Comment thread
coderabbitai[bot] marked this conversation as resolved.

class TestGitHubAPIError:
"""Tests for GitHubAPIError exception."""

Expand Down
56 changes: 56 additions & 0 deletions web-ui/src/__tests__/components/proof/CaptureGlitchModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -234,4 +234,60 @@ describe('CaptureGlitchModal', () => {
expect((screen.getByLabelText(/Description/i) as HTMLTextAreaElement).value).toBe('');
});
});

describe('pre-population from PR', () => {
const PR_PROPS = {
...DEFAULT_PROPS,
prNumber: 42,
prTitle: 'Fix login timeout',
prUrl: 'https://github.com/owner/repo/pull/42',
initialScope: 'src/auth.py\nsrc/utils.py',
};

it('pre-fills description with PR reference when prNumber is provided', () => {
setup(PR_PROPS);
const textarea = screen.getByLabelText(/Description/i) as HTMLTextAreaElement;
expect(textarea.value).toBe('Reported from PR #42: Fix login timeout');
});

it('pre-fills scope with initialScope', () => {
setup(PR_PROPS);
const textarea = screen.getByLabelText(/Scope/i) as HTMLTextAreaElement;
expect(textarea.value).toBe('src/auth.py\nsrc/utils.py');
});

it('includes source_issue in submission payload', async () => {
mockCapture.mockResolvedValue(MOCK_REQ);
setup(PR_PROPS);

// Fill required fields
fireEvent.click(screen.getByRole('checkbox', { name: 'unit' }));
fireEvent.click(screen.getByRole('button', { name: /Capture Glitch/i }));

await waitFor(() => {
expect(mockCapture).toHaveBeenCalledWith(
WORKSPACE,
expect.objectContaining({
source_issue: 'https://github.com/owner/repo/pull/42',
})
);
});
});

it('does not include source_issue when prUrl is not provided', async () => {
mockCapture.mockResolvedValue(MOCK_REQ);
setup();

fireEvent.change(screen.getByLabelText(/Description/i), {
target: { value: 'Something broke' },
});
fireEvent.click(screen.getByRole('checkbox', { name: 'unit' }));
fireEvent.click(screen.getByRole('button', { name: /Capture Glitch/i }));

await waitFor(() => {
const callArgs = mockCapture.mock.calls[0][1];
expect(callArgs.source_issue).toBeUndefined();
});
});
});
});
39 changes: 39 additions & 0 deletions web-ui/src/__tests__/components/proof/ProofDetailPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -282,3 +282,42 @@ describe('ProofDetailPage — combined filters', () => {
expect(evidenceRows()).toHaveLength(1);
});
});

describe('ProofDetailPage — source_issue rendering', () => {
beforeEach(() => jest.clearAllMocks());

function setupWithReq(reqOverrides: Partial<ProofRequirement>) {
const req = { ...REQ, ...reqOverrides };
mockGetWorkspace.mockReturnValue(WORKSPACE);
mockUseSWR.mockImplementation((key: unknown) => {
if (!key) {
return { data: undefined, error: undefined, isLoading: false, mutate: jest.fn() } as unknown as ReturnType<typeof useSWR>;
}
const k = String(key);
if (k.includes('/evidence')) {
return { data: [], error: undefined, isLoading: false, mutate: jest.fn() } as unknown as ReturnType<typeof useSWR>;
}
return { data: req, error: undefined, isLoading: false, mutate: jest.fn() } as unknown as ReturnType<typeof useSWR>;
});
render(<ProofDetailPage />);
}

it('renders source_issue as a clickable link when it is a GitHub URL', () => {
setupWithReq({ source_issue: 'https://github.com/owner/repo/pull/42' });
const link = screen.getByRole('link', { name: /github\.com/i });
expect(link).toHaveAttribute('href', 'https://github.com/owner/repo/pull/42');
expect(link).toHaveAttribute('target', '_blank');
});

it('renders source_issue as plain text when it is not a URL', () => {
setupWithReq({ source_issue: 'JIRA-123' });
expect(screen.getByText(/JIRA-123/)).toBeInTheDocument();
expect(screen.queryByRole('link', { name: /JIRA-123/ })).not.toBeInTheDocument();
});

it('does not render source_issue when it is null', () => {
setupWithReq({ source_issue: null });
expect(screen.queryByText(/Source PR/)).not.toBeInTheDocument();
expect(screen.queryByText(/Issue:/)).not.toBeInTheDocument();
});
});
44 changes: 42 additions & 2 deletions web-ui/src/__tests__/components/review/PRHistoryPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import useSWR from 'swr';
import { PRHistoryPanel } from '@/components/review/PRHistoryPanel';
import { prApi } from '@/lib/api';
import type { PRHistoryResponse } from '@/types';

// ── Mocks ─────────────────────────────────────────────────────────────────

jest.mock('swr');
jest.mock('@/lib/api', () => ({
prApi: { getHistory: jest.fn() },
prApi: { getHistory: jest.fn(), getFiles: jest.fn() },
proofApi: { capture: jest.fn() },
}));

const mockGetFiles = prApi.getFiles as jest.MockedFunction<typeof prApi.getFiles>;

const mockUseSWR = useSWR as jest.MockedFunction<typeof useSWR>;

// ── Helpers ───────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -233,4 +237,40 @@ describe('PRHistoryPanel', () => {
expect(key).toBeNull();
});
});

describe('Report Glitch button', () => {
it('renders a Report Glitch button for each PR row', () => {
withData(SAMPLE_HISTORY);
render(<PRHistoryPanel workspacePath={WORKSPACE} />);

const buttons = screen.getAllByRole('button', { name: /Report Glitch/i });
expect(buttons).toHaveLength(2);
});

it('fetches PR files when Report Glitch is clicked', async () => {
mockGetFiles.mockResolvedValue(['src/auth.py', 'tests/test_auth.py']);
withData(SAMPLE_HISTORY);
render(<PRHistoryPanel workspacePath={WORKSPACE} />);

const buttons = screen.getAllByRole('button', { name: /Report Glitch/i });
fireEvent.click(buttons[0]);

await waitFor(() => {
expect(mockGetFiles).toHaveBeenCalledWith(WORKSPACE, 10);
});
});

it('opens the capture modal after files are fetched', async () => {
mockGetFiles.mockResolvedValue(['src/auth.py']);
withData(SAMPLE_HISTORY);
render(<PRHistoryPanel workspacePath={WORKSPACE} />);

const buttons = screen.getAllByRole('button', { name: /Report Glitch/i });
fireEvent.click(buttons[0]);

await waitFor(() => {
expect(screen.getByRole('heading', { name: 'Capture Glitch' })).toBeInTheDocument();
});
});
});
});
Loading
Loading