diff --git a/web-ui/__tests__/api/git.test.ts b/web-ui/__tests__/api/git.test.ts new file mode 100644 index 00000000..6cb1b2e8 --- /dev/null +++ b/web-ui/__tests__/api/git.test.ts @@ -0,0 +1,213 @@ +/** + * Git API Client Tests + * + * Tests for the Git API client functions. + * Uses mocked fetch to verify correct API calls. + */ + +import { + getGitStatus, + getCommits, + getBranches, + getBranch, +} from '@/api/git'; +import type { GitStatus, GitCommit, GitBranch } from '@/types/git'; + +// Mock localStorage +const mockLocalStorage = { + getItem: jest.fn((): string | null => 'mock-auth-token'), + setItem: jest.fn(), + removeItem: jest.fn(), + clear: jest.fn(), + length: 0, + key: jest.fn(), +}; +Object.defineProperty(window, 'localStorage', { value: mockLocalStorage }); + +// Mock fetch globally +const mockFetch = jest.fn(); +global.fetch = mockFetch; + +describe('Git API Client', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockLocalStorage.getItem.mockReturnValue('mock-auth-token'); + }); + + describe('getGitStatus', () => { + it('should fetch git status for a project', async () => { + const mockStatus: GitStatus = { + current_branch: 'feature/auth', + is_dirty: false, + modified_files: [], + untracked_files: [], + staged_files: [], + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(JSON.stringify(mockStatus)), + }); + + const result = await getGitStatus(123); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/api/projects/123/git/status'), + expect.objectContaining({ + headers: expect.objectContaining({ + 'Authorization': 'Bearer mock-auth-token', + }), + }) + ); + expect(result).toEqual(mockStatus); + }); + + it('should throw error when not authenticated', async () => { + mockLocalStorage.getItem.mockReturnValueOnce(null); + + await expect(getGitStatus(123)).rejects.toThrow('Not authenticated'); + }); + + it('should throw error on API failure', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + text: () => Promise.resolve('Not found'), + }); + + await expect(getGitStatus(123)).rejects.toThrow(); + }); + }); + + describe('getCommits', () => { + it('should fetch commits with default limit', async () => { + const mockCommits: GitCommit[] = [ + { + hash: 'abc123def456', + short_hash: 'abc123d', + message: 'feat: Add login', + author: 'Agent', + timestamp: '2025-01-01T00:00:00Z', + files_changed: 3, + }, + ]; + + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(JSON.stringify({ commits: mockCommits })), + }); + + const result = await getCommits(123); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/api/projects/123/git/commits'), + expect.any(Object) + ); + expect(result).toEqual(mockCommits); + }); + + it('should fetch commits with custom limit', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(JSON.stringify({ commits: [] })), + }); + + await getCommits(123, { limit: 5 }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('limit=5'), + expect.any(Object) + ); + }); + + it('should fetch commits for specific branch', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(JSON.stringify({ commits: [] })), + }); + + await getCommits(123, { branch: 'feature/test' }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('branch=feature%2Ftest'), + expect.any(Object) + ); + }); + }); + + describe('getBranches', () => { + it('should fetch branches with default status filter', async () => { + const mockBranches: GitBranch[] = [ + { + id: 1, + branch_name: 'feature/auth', + issue_id: 10, + status: 'active', + created_at: '2025-01-01T00:00:00Z', + }, + ]; + + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(JSON.stringify({ branches: mockBranches })), + }); + + const result = await getBranches(123); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/api/projects/123/git/branches'), + expect.any(Object) + ); + expect(result).toEqual(mockBranches); + }); + + it('should filter branches by status', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(JSON.stringify({ branches: [] })), + }); + + await getBranches(123, 'merged'); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('status=merged'), + expect.any(Object) + ); + }); + }); + + describe('getBranch', () => { + it('should fetch single branch by name', async () => { + const mockBranch: GitBranch = { + id: 1, + branch_name: 'feature/auth', + issue_id: 10, + status: 'active', + created_at: '2025-01-01T00:00:00Z', + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(JSON.stringify(mockBranch)), + }); + + const result = await getBranch(123, 'feature/auth'); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/api/projects/123/git/branches/feature%2Fauth'), + expect.any(Object) + ); + expect(result).toEqual(mockBranch); + }); + + it('should handle branch not found', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + text: () => Promise.resolve('Branch not found'), + }); + + await expect(getBranch(123, 'nonexistent')).rejects.toThrow(); + }); + }); +}); diff --git a/web-ui/__tests__/components/git/BranchList.test.tsx b/web-ui/__tests__/components/git/BranchList.test.tsx new file mode 100644 index 00000000..4ae30a02 --- /dev/null +++ b/web-ui/__tests__/components/git/BranchList.test.tsx @@ -0,0 +1,135 @@ +/** + * BranchList Component Tests + * + * Tests for the branch list component that displays + * all git branches with status indicators. + */ + +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import BranchList from '@/components/git/BranchList'; +import type { GitBranch } from '@/types/git'; + +const mockBranches: GitBranch[] = [ + { + id: 1, + branch_name: 'feature/auth', + issue_id: 10, + status: 'active', + created_at: '2025-01-01T00:00:00Z', + }, + { + id: 2, + branch_name: 'feature/dashboard', + issue_id: 20, + status: 'merged', + created_at: '2025-01-01T00:00:00Z', + merged_at: '2025-01-02T00:00:00Z', + merge_commit: 'abc123', + }, + { + id: 3, + branch_name: 'feature/abandoned', + issue_id: 30, + status: 'abandoned', + created_at: '2025-01-01T00:00:00Z', + }, +]; + +describe('BranchList', () => { + describe('rendering', () => { + it('should render empty state when no branches', () => { + render(); + + expect(screen.getByText(/no branches/i)).toBeInTheDocument(); + }); + + it('should render list of branches', () => { + render(); + + expect(screen.getByText('feature/auth')).toBeInTheDocument(); + expect(screen.getByText('feature/dashboard')).toBeInTheDocument(); + expect(screen.getByText('feature/abandoned')).toBeInTheDocument(); + }); + + it('should render branch count in header', () => { + render(); + + expect(screen.getByText(/\(3\)/)).toBeInTheDocument(); + }); + }); + + describe('status badges', () => { + it('should show active status badge', () => { + render(); + + expect(screen.getByText('active')).toBeInTheDocument(); + }); + + it('should show merged status badge', () => { + render(); + + expect(screen.getByText('merged')).toBeInTheDocument(); + }); + + it('should show abandoned status badge', () => { + render(); + + expect(screen.getByText('abandoned')).toBeInTheDocument(); + }); + + it('should use correct styling for active branch', () => { + render(); + + const badge = screen.getByText('active'); + expect(badge).toHaveClass('bg-primary/10'); + }); + + it('should use correct styling for merged branch', () => { + render(); + + const badge = screen.getByText('merged'); + expect(badge).toHaveClass('bg-secondary/10'); + }); + + it('should use correct styling for abandoned branch', () => { + render(); + + const badge = screen.getByText('abandoned'); + expect(badge).toHaveClass('bg-muted'); + }); + }); + + describe('merged branch info', () => { + it('should show merge commit for merged branches', () => { + render(); + + expect(screen.getByText(/abc123/i)).toBeInTheDocument(); + }); + }); + + describe('loading state', () => { + it('should show loading state', () => { + render(); + + expect(screen.getByTestId('branches-loading')).toBeInTheDocument(); + }); + }); + + describe('error state', () => { + it('should show error message', () => { + render(); + + expect(screen.getByText(/failed to load branches/i)).toBeInTheDocument(); + }); + }); + + describe('filtering', () => { + it('should filter by status when provided', () => { + render(); + + expect(screen.getByText('feature/auth')).toBeInTheDocument(); + expect(screen.queryByText('feature/dashboard')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/web-ui/__tests__/components/git/CommitHistory.test.tsx b/web-ui/__tests__/components/git/CommitHistory.test.tsx new file mode 100644 index 00000000..4130dbf6 --- /dev/null +++ b/web-ui/__tests__/components/git/CommitHistory.test.tsx @@ -0,0 +1,126 @@ +/** + * CommitHistory Component Tests + * + * Tests for the commit history list component that displays + * recent git commits with expandable details. + */ + +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import CommitHistory from '@/components/git/CommitHistory'; +import type { GitCommit } from '@/types/git'; + +const mockCommits: GitCommit[] = [ + { + hash: 'abc123def456789', + short_hash: 'abc123d', + message: 'feat: Add user authentication', + author: 'Agent ', + timestamp: '2025-01-01T12:00:00Z', + files_changed: 5, + }, + { + hash: 'def456abc789123', + short_hash: 'def456a', + message: 'fix: Resolve login bug', + author: 'Agent ', + timestamp: '2025-01-01T11:00:00Z', + files_changed: 2, + }, +]; + +describe('CommitHistory', () => { + describe('rendering', () => { + it('should render empty state when no commits', () => { + render(); + + expect(screen.getByText(/no commits/i)).toBeInTheDocument(); + }); + + it('should render list of commits', () => { + render(); + + expect(screen.getByText('abc123d')).toBeInTheDocument(); + expect(screen.getByText('def456a')).toBeInTheDocument(); + }); + + it('should display commit messages', () => { + render(); + + expect(screen.getByText('feat: Add user authentication')).toBeInTheDocument(); + expect(screen.getByText('fix: Resolve login bug')).toBeInTheDocument(); + }); + + it('should display file count when available', () => { + render(); + + expect(screen.getByText(/5 files/i)).toBeInTheDocument(); + expect(screen.getByText(/2 files/i)).toBeInTheDocument(); + }); + + it('should display relative timestamps', () => { + render(); + + // Since dates are in the past, they should show relative time + const timeElements = screen.getAllByRole('time'); + expect(timeElements.length).toBe(2); + }); + }); + + describe('loading state', () => { + it('should show loading state', () => { + render(); + + expect(screen.getByTestId('commits-loading')).toBeInTheDocument(); + }); + }); + + describe('error state', () => { + it('should show error message', () => { + render(); + + expect(screen.getByText(/failed to load commits/i)).toBeInTheDocument(); + }); + }); + + describe('commit hash link', () => { + it('should show short hash as monospace text', () => { + render(); + + const hash = screen.getByText('abc123d'); + expect(hash).toHaveClass('font-mono'); + }); + }); + + describe('commit limit', () => { + it('should respect max items limit', () => { + const manyCommits = Array.from({ length: 20 }, (_, i) => ({ + hash: `hash${i}`, + short_hash: `hash${i}`.slice(0, 7), + message: `Commit ${i}`, + author: 'Agent', + timestamp: '2025-01-01T00:00:00Z', + })); + + render(); + + const commitItems = screen.getAllByTestId('commit-item'); + expect(commitItems.length).toBe(5); + }); + }); + + describe('header', () => { + it('should display title', () => { + render(); + + expect(screen.getByRole('heading')).toHaveTextContent(/commits/i); + }); + + it('should display commit count in header', () => { + render(); + + // Look for the count in parentheses format "(2)" + expect(screen.getByText(/\(2\)/)).toBeInTheDocument(); + }); + }); +}); diff --git a/web-ui/__tests__/components/git/GitBranchIndicator.test.tsx b/web-ui/__tests__/components/git/GitBranchIndicator.test.tsx new file mode 100644 index 00000000..37ef967a --- /dev/null +++ b/web-ui/__tests__/components/git/GitBranchIndicator.test.tsx @@ -0,0 +1,133 @@ +/** + * GitBranchIndicator Component Tests + * + * Tests for the Git branch indicator component that displays + * the current branch name and status in the Dashboard header. + */ + +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import GitBranchIndicator from '@/components/git/GitBranchIndicator'; +import type { GitStatus } from '@/types/git'; + +describe('GitBranchIndicator', () => { + describe('rendering', () => { + it('should render nothing when status is null', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('should render branch name', () => { + const status: GitStatus = { + current_branch: 'feature/auth', + is_dirty: false, + modified_files: [], + untracked_files: [], + staged_files: [], + }; + + render(); + + expect(screen.getByText('feature/auth')).toBeInTheDocument(); + }); + + it('should show branch icon', () => { + const status: GitStatus = { + current_branch: 'main', + is_dirty: false, + modified_files: [], + untracked_files: [], + staged_files: [], + }; + + render(); + + // The component should have some visual branch indicator + const indicator = screen.getByTestId('branch-indicator'); + expect(indicator).toBeInTheDocument(); + }); + }); + + describe('dirty state indicator', () => { + it('should show clean state when not dirty', () => { + const status: GitStatus = { + current_branch: 'main', + is_dirty: false, + modified_files: [], + untracked_files: [], + staged_files: [], + }; + + render(); + + // Should not show dirty indicator + expect(screen.queryByTestId('dirty-indicator')).not.toBeInTheDocument(); + }); + + it('should show dirty indicator when dirty', () => { + const status: GitStatus = { + current_branch: 'main', + is_dirty: true, + modified_files: ['file.ts'], + untracked_files: [], + staged_files: [], + }; + + render(); + + expect(screen.getByTestId('dirty-indicator')).toBeInTheDocument(); + }); + + it('should show modified file count in tooltip/title', () => { + const status: GitStatus = { + current_branch: 'main', + is_dirty: true, + modified_files: ['a.ts', 'b.ts'], + untracked_files: ['c.ts'], + staged_files: ['d.ts'], + }; + + render(); + + // 4 total changes (2 modified + 1 untracked + 1 staged) + const indicator = screen.getByTestId('branch-indicator'); + expect(indicator).toHaveAttribute('title', expect.stringContaining('4')); + }); + }); + + describe('loading state', () => { + it('should show loading state when isLoading is true', () => { + render(); + + expect(screen.getByTestId('branch-loading')).toBeInTheDocument(); + }); + }); + + describe('error state', () => { + it('should show error state when error is present', () => { + render(); + + expect(screen.getByTestId('branch-error')).toBeInTheDocument(); + }); + }); + + describe('styling', () => { + it('should use muted styling for long branch names', () => { + const status: GitStatus = { + current_branch: 'feature/very-long-branch-name-that-needs-truncation', + is_dirty: false, + modified_files: [], + untracked_files: [], + staged_files: [], + }; + + render(); + + const branchName = screen.getByText(/feature\/very-long/); + // First verify the element is rendered and visible + expect(branchName).toBeVisible(); + // Then verify the truncation styling + expect(branchName.closest('[data-testid="branch-indicator"]')).toHaveClass('truncate'); + }); + }); +}); diff --git a/web-ui/__tests__/components/git/GitSection.test.tsx b/web-ui/__tests__/components/git/GitSection.test.tsx new file mode 100644 index 00000000..e0030d31 --- /dev/null +++ b/web-ui/__tests__/components/git/GitSection.test.tsx @@ -0,0 +1,154 @@ +/** + * GitSection Component Tests + * + * Tests for the Git section container component that combines + * GitBranchIndicator, CommitHistory, and BranchList. + */ + +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import GitSection from '@/components/git/GitSection'; +import * as gitApi from '@/api/git'; +import type { GitStatus, GitCommit, GitBranch } from '@/types/git'; + +// Mock SWR +jest.mock('swr', () => ({ + __esModule: true, + default: jest.fn(), +})); + +// Mock the Git API +jest.mock('@/api/git'); + +const mockGitApi = gitApi as jest.Mocked; + +const mockStatus: GitStatus = { + current_branch: 'feature/test', + is_dirty: false, + modified_files: [], + untracked_files: [], + staged_files: [], +}; + +const mockCommits: GitCommit[] = [ + { + hash: 'abc123', + short_hash: 'abc123', + message: 'Test commit', + author: 'Agent', + timestamp: '2025-01-01T00:00:00Z', + }, +]; + +const mockBranches: GitBranch[] = [ + { + id: 1, + branch_name: 'feature/test', + issue_id: 10, + status: 'active', + created_at: '2025-01-01T00:00:00Z', + }, +]; + +// Get the mocked SWR +import useSWR from 'swr'; +const mockUseSWR = useSWR as jest.Mock; + +describe('GitSection', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('loading state', () => { + it('should show loading state initially', () => { + mockUseSWR.mockImplementation(() => ({ + data: undefined, + error: undefined, + isLoading: true, + })); + + render(); + + expect(screen.getByTestId('git-section-loading')).toBeInTheDocument(); + }); + }); + + describe('error state', () => { + it('should show error state when API fails', () => { + mockUseSWR.mockImplementation(() => ({ + data: undefined, + error: new Error('API Error'), + isLoading: false, + })); + + render(); + + expect(screen.getByTestId('git-section-error')).toBeInTheDocument(); + }); + }); + + describe('data display', () => { + it('should render all Git components when data is loaded', () => { + mockUseSWR.mockImplementation((key: string) => { + if (key.includes('status')) { + return { data: mockStatus, error: undefined, isLoading: false }; + } + if (key.includes('commits')) { + return { data: mockCommits, error: undefined, isLoading: false }; + } + if (key.includes('branches')) { + return { data: mockBranches, error: undefined, isLoading: false }; + } + return { data: undefined, error: undefined, isLoading: false }; + }); + + render(); + + // Should show section header + expect(screen.getByText(/code & git/i)).toBeInTheDocument(); + }); + + it('should pass correct data to child components', () => { + mockUseSWR.mockImplementation((key: string) => { + if (key.includes('status')) { + return { data: mockStatus, error: undefined, isLoading: false }; + } + if (key.includes('commits')) { + return { data: mockCommits, error: undefined, isLoading: false }; + } + if (key.includes('branches')) { + return { data: mockBranches, error: undefined, isLoading: false }; + } + return { data: undefined, error: undefined, isLoading: false }; + }); + + render(); + + // Check branch name appears (in indicator and/or branch list) + const branchElements = screen.getAllByText('feature/test'); + expect(branchElements.length).toBeGreaterThan(0); + }); + }); + + describe('collapsible sections', () => { + it('should render with expandable sections', () => { + mockUseSWR.mockImplementation((key: string) => { + if (key.includes('status')) { + return { data: mockStatus, error: undefined, isLoading: false }; + } + if (key.includes('commits')) { + return { data: mockCommits, error: undefined, isLoading: false }; + } + if (key.includes('branches')) { + return { data: mockBranches, error: undefined, isLoading: false }; + } + return { data: undefined, error: undefined, isLoading: false }; + }); + + render(); + + // Section should be in the document + expect(screen.getByTestId('git-section')).toBeInTheDocument(); + }); + }); +}); diff --git a/web-ui/__tests__/lib/websocketMessageMapper.test.ts b/web-ui/__tests__/lib/websocketMessageMapper.test.ts index 45a06f07..d5185654 100644 --- a/web-ui/__tests__/lib/websocketMessageMapper.test.ts +++ b/web-ui/__tests__/lib/websocketMessageMapper.test.ts @@ -691,4 +691,209 @@ describe('mapWebSocketMessageToAction', () => { }); }); }); + + describe('commit_created message validation (Ticket #272)', () => { + it('should map commit_created message with all required fields', () => { + const message = { + type: 'commit_created' as const, + project_id: projectId, + timestamp: '2023-11-14T12:00:00Z', + commit_hash: 'abc123def456', + commit_message: 'feat: Add new feature', + agent: 'backend-worker-1', + task_id: 123, + files_changed: ['file1.ts', 'file2.ts'], + }; + + const action = mapWebSocketMessageToAction(message); + + expect(action).toEqual({ + type: 'COMMIT_CREATED', + payload: { + commit: { + hash: 'abc123def456', + short_hash: 'abc123d', + message: 'feat: Add new feature', + author: 'backend-worker-1', + timestamp: '2023-11-14T12:00:00Z', + files_changed: 2, + }, + taskId: 123, + timestamp: new Date('2023-11-14T12:00:00Z').getTime(), + }, + }); + }); + + it('should return null when commit_hash is missing', () => { + const message = { + type: 'commit_created' as const, + project_id: projectId, + timestamp: baseTimestamp, + commit_message: 'feat: Add new feature', + // Missing commit_hash + }; + + const action = mapWebSocketMessageToAction(message); + + expect(action).toBeNull(); + }); + + it('should return null when commit_message is missing', () => { + const message = { + type: 'commit_created' as const, + project_id: projectId, + timestamp: baseTimestamp, + commit_hash: 'abc123def456', + // Missing commit_message + }; + + const action = mapWebSocketMessageToAction(message); + + expect(action).toBeNull(); + }); + + it('should log warning when required fields are missing in development', () => { + const originalEnv = process.env; + process.env = { ...originalEnv, NODE_ENV: 'development' }; + + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + + const message = { + type: 'commit_created' as const, + project_id: projectId, + timestamp: baseTimestamp, + // Missing required fields + }; + + mapWebSocketMessageToAction(message); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('commit_created message missing required fields'), + expect.anything() + ); + + consoleSpy.mockRestore(); + process.env = originalEnv; + }); + }); + + describe('branch_created message validation (Ticket #272)', () => { + it('should map branch_created message with all required fields', () => { + const message = { + type: 'branch_created' as const, + project_id: projectId, + timestamp: '2023-11-14T12:00:00Z', + data: { + id: 1, + branch_name: 'feature/auth', + issue_id: 42, + }, + }; + + const action = mapWebSocketMessageToAction(message); + + expect(action).toEqual({ + type: 'BRANCH_CREATED', + payload: { + branch: { + id: 1, + branch_name: 'feature/auth', + issue_id: 42, + status: 'active', + created_at: '2023-11-14T12:00:00Z', + }, + timestamp: new Date('2023-11-14T12:00:00Z').getTime(), + }, + }); + }); + + it('should return null when data.id is missing', () => { + const message = { + type: 'branch_created' as const, + project_id: projectId, + timestamp: baseTimestamp, + data: { + branch_name: 'feature/auth', + // Missing id + }, + }; + + const action = mapWebSocketMessageToAction(message); + + expect(action).toBeNull(); + }); + + it('should return null when data.branch_name is missing', () => { + const message = { + type: 'branch_created' as const, + project_id: projectId, + timestamp: baseTimestamp, + data: { + id: 1, + // Missing branch_name + }, + }; + + const action = mapWebSocketMessageToAction(message); + + expect(action).toBeNull(); + }); + + it('should return null when data is missing entirely', () => { + const message = { + type: 'branch_created' as const, + project_id: projectId, + timestamp: baseTimestamp, + // Missing data + }; + + const action = mapWebSocketMessageToAction(message); + + expect(action).toBeNull(); + }); + + it('should default issue_id to 0 when not provided', () => { + const message = { + type: 'branch_created' as const, + project_id: projectId, + timestamp: baseTimestamp, + data: { + id: 1, + branch_name: 'feature/auth', + // Missing issue_id + }, + }; + + const action = mapWebSocketMessageToAction(message); + + expect(action).toBeTruthy(); + expect((action?.payload as any).branch.issue_id).toBe(0); + }); + + it('should log warning when required fields are missing in development', () => { + const originalEnv = process.env; + process.env = { ...originalEnv, NODE_ENV: 'development' }; + + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + + const message = { + type: 'branch_created' as const, + project_id: projectId, + timestamp: baseTimestamp, + data: { + // Missing required fields + }, + }; + + mapWebSocketMessageToAction(message); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('branch_created message missing required fields'), + expect.anything() + ); + + consoleSpy.mockRestore(); + process.env = originalEnv; + }); + }); }); \ No newline at end of file diff --git a/web-ui/__tests__/reducers/gitActions.test.ts b/web-ui/__tests__/reducers/gitActions.test.ts new file mode 100644 index 00000000..60be14bb --- /dev/null +++ b/web-ui/__tests__/reducers/gitActions.test.ts @@ -0,0 +1,375 @@ +/** + * Git Reducer Actions Tests + * + * Tests for Git-related reducer action handlers. + * Following TDD approach - these tests are written before implementation. + * + * Ticket: #272 - Git Visualization + */ + +import { agentReducer, getInitialState } from '@/reducers/agentReducer'; +import type { + AgentState, + GitStatusLoadedAction, + GitCommitsLoadedAction, + GitBranchesLoadedAction, + CommitCreatedAction, + BranchCreatedAction, + GitLoadingAction, + GitErrorAction, +} from '@/types/agentState'; +import type { GitState, GitStatus, GitCommit, GitBranch } from '@/types/git'; +import { INITIAL_GIT_STATE } from '@/types/git'; + +// ============================================================================ +// Test Fixtures +// ============================================================================ + +function createMockGitStatus(overrides: Partial = {}): GitStatus { + return { + current_branch: 'feature/test', + is_dirty: false, + modified_files: [], + untracked_files: [], + staged_files: [], + ...overrides, + }; +} + +function createMockGitCommit(overrides: Partial = {}): GitCommit { + return { + hash: 'abc123def456789', + short_hash: 'abc123d', + message: 'feat: Add test feature', + author: 'Agent ', + timestamp: '2025-01-01T12:00:00Z', + ...overrides, + }; +} + +function createMockGitBranch(overrides: Partial = {}): GitBranch { + return { + id: 1, + branch_name: 'feature/test', + issue_id: 10, + status: 'active', + created_at: '2025-01-01T00:00:00Z', + ...overrides, + }; +} + +function createStateWithGitState(gitState: Partial = {}): AgentState { + return { + ...getInitialState(), + gitState: { + ...INITIAL_GIT_STATE, + ...gitState, + }, + }; +} + +// ============================================================================ +// GIT_STATUS_LOADED Tests +// ============================================================================ + +describe('GIT_STATUS_LOADED', () => { + it('should initialize gitState when null', () => { + const initialState = getInitialState(); + expect(initialState.gitState).toBeNull(); + + const status = createMockGitStatus(); + const action: GitStatusLoadedAction = { + type: 'GIT_STATUS_LOADED', + payload: { status, timestamp: Date.now() }, + }; + + const newState = agentReducer(initialState, action); + + expect(newState.gitState).not.toBeNull(); + expect(newState.gitState?.status).toEqual(status); + expect(newState.gitState?.isLoading).toBe(false); + expect(newState.gitState?.error).toBeNull(); + }); + + it('should update existing gitState with new status', () => { + const initialState = createStateWithGitState({ + status: createMockGitStatus({ current_branch: 'main' }), + }); + + const newStatus = createMockGitStatus({ current_branch: 'feature/new' }); + const action: GitStatusLoadedAction = { + type: 'GIT_STATUS_LOADED', + payload: { status: newStatus, timestamp: Date.now() }, + }; + + const newState = agentReducer(initialState, action); + + expect(newState.gitState?.status?.current_branch).toBe('feature/new'); + }); + + it('should preserve commits and branches when updating status', () => { + const commits = [createMockGitCommit()]; + const branches = [createMockGitBranch()]; + const initialState = createStateWithGitState({ + recentCommits: commits, + branches: branches, + }); + + const action: GitStatusLoadedAction = { + type: 'GIT_STATUS_LOADED', + payload: { status: createMockGitStatus(), timestamp: Date.now() }, + }; + + const newState = agentReducer(initialState, action); + + expect(newState.gitState?.recentCommits).toEqual(commits); + expect(newState.gitState?.branches).toEqual(branches); + }); +}); + +// ============================================================================ +// GIT_COMMITS_LOADED Tests +// ============================================================================ + +describe('GIT_COMMITS_LOADED', () => { + it('should load commits into gitState', () => { + const initialState = createStateWithGitState(); + const commits = [ + createMockGitCommit({ hash: 'commit1' }), + createMockGitCommit({ hash: 'commit2' }), + ]; + + const action: GitCommitsLoadedAction = { + type: 'GIT_COMMITS_LOADED', + payload: { commits, timestamp: Date.now() }, + }; + + const newState = agentReducer(initialState, action); + + expect(newState.gitState?.recentCommits).toEqual(commits); + expect(newState.gitState?.recentCommits).toHaveLength(2); + }); + + it('should replace existing commits', () => { + const initialState = createStateWithGitState({ + recentCommits: [createMockGitCommit({ hash: 'old' })], + }); + + const newCommits = [createMockGitCommit({ hash: 'new' })]; + const action: GitCommitsLoadedAction = { + type: 'GIT_COMMITS_LOADED', + payload: { commits: newCommits, timestamp: Date.now() }, + }; + + const newState = agentReducer(initialState, action); + + expect(newState.gitState?.recentCommits).toHaveLength(1); + expect(newState.gitState?.recentCommits[0].hash).toBe('new'); + }); +}); + +// ============================================================================ +// GIT_BRANCHES_LOADED Tests +// ============================================================================ + +describe('GIT_BRANCHES_LOADED', () => { + it('should load branches into gitState', () => { + const initialState = createStateWithGitState(); + const branches = [ + createMockGitBranch({ id: 1, branch_name: 'feature/a' }), + createMockGitBranch({ id: 2, branch_name: 'feature/b' }), + ]; + + const action: GitBranchesLoadedAction = { + type: 'GIT_BRANCHES_LOADED', + payload: { branches, timestamp: Date.now() }, + }; + + const newState = agentReducer(initialState, action); + + expect(newState.gitState?.branches).toEqual(branches); + expect(newState.gitState?.branches).toHaveLength(2); + }); +}); + +// ============================================================================ +// COMMIT_CREATED Tests +// ============================================================================ + +describe('COMMIT_CREATED', () => { + it('should prepend new commit to recentCommits', () => { + const existingCommit = createMockGitCommit({ hash: 'existing' }); + const initialState = createStateWithGitState({ + recentCommits: [existingCommit], + }); + + const newCommit = createMockGitCommit({ hash: 'new-commit' }); + const action: CommitCreatedAction = { + type: 'COMMIT_CREATED', + payload: { commit: newCommit, timestamp: Date.now() }, + }; + + const newState = agentReducer(initialState, action); + + expect(newState.gitState?.recentCommits).toHaveLength(2); + expect(newState.gitState?.recentCommits[0].hash).toBe('new-commit'); + expect(newState.gitState?.recentCommits[1].hash).toBe('existing'); + }); + + it('should limit recentCommits to 10 items (FIFO)', () => { + const existingCommits = Array.from({ length: 10 }, (_, i) => + createMockGitCommit({ hash: `commit-${i}` }) + ); + const initialState = createStateWithGitState({ + recentCommits: existingCommits, + }); + + const newCommit = createMockGitCommit({ hash: 'newest' }); + const action: CommitCreatedAction = { + type: 'COMMIT_CREATED', + payload: { commit: newCommit, timestamp: Date.now() }, + }; + + const newState = agentReducer(initialState, action); + + expect(newState.gitState?.recentCommits).toHaveLength(10); + expect(newState.gitState?.recentCommits[0].hash).toBe('newest'); + // Oldest commit should be dropped + expect(newState.gitState?.recentCommits.find(c => c.hash === 'commit-9')).toBeUndefined(); + }); + + it('should initialize gitState if null when commit created', () => { + const initialState = getInitialState(); + expect(initialState.gitState).toBeNull(); + + const newCommit = createMockGitCommit(); + const action: CommitCreatedAction = { + type: 'COMMIT_CREATED', + payload: { commit: newCommit, timestamp: Date.now() }, + }; + + const newState = agentReducer(initialState, action); + + expect(newState.gitState).not.toBeNull(); + expect(newState.gitState?.recentCommits).toHaveLength(1); + }); +}); + +// ============================================================================ +// BRANCH_CREATED Tests +// ============================================================================ + +describe('BRANCH_CREATED', () => { + it('should add new branch to branches array', () => { + const existingBranch = createMockGitBranch({ id: 1, branch_name: 'existing' }); + const initialState = createStateWithGitState({ + branches: [existingBranch], + }); + + const newBranch = createMockGitBranch({ id: 2, branch_name: 'new-branch' }); + const action: BranchCreatedAction = { + type: 'BRANCH_CREATED', + payload: { branch: newBranch, timestamp: Date.now() }, + }; + + const newState = agentReducer(initialState, action); + + expect(newState.gitState?.branches).toHaveLength(2); + expect(newState.gitState?.branches.find(b => b.branch_name === 'new-branch')).toBeDefined(); + }); + + it('should initialize gitState if null when branch created', () => { + const initialState = getInitialState(); + expect(initialState.gitState).toBeNull(); + + const newBranch = createMockGitBranch(); + const action: BranchCreatedAction = { + type: 'BRANCH_CREATED', + payload: { branch: newBranch, timestamp: Date.now() }, + }; + + const newState = agentReducer(initialState, action); + + expect(newState.gitState).not.toBeNull(); + expect(newState.gitState?.branches).toHaveLength(1); + }); +}); + +// ============================================================================ +// GIT_LOADING Tests +// ============================================================================ + +describe('GIT_LOADING', () => { + it('should set isLoading to true', () => { + const initialState = createStateWithGitState({ isLoading: false }); + + const action: GitLoadingAction = { + type: 'GIT_LOADING', + payload: true, + }; + + const newState = agentReducer(initialState, action); + + expect(newState.gitState?.isLoading).toBe(true); + }); + + it('should set isLoading to false', () => { + const initialState = createStateWithGitState({ isLoading: true }); + + const action: GitLoadingAction = { + type: 'GIT_LOADING', + payload: false, + }; + + const newState = agentReducer(initialState, action); + + expect(newState.gitState?.isLoading).toBe(false); + }); + + it('should initialize gitState if null', () => { + const initialState = getInitialState(); + + const action: GitLoadingAction = { + type: 'GIT_LOADING', + payload: true, + }; + + const newState = agentReducer(initialState, action); + + expect(newState.gitState).not.toBeNull(); + expect(newState.gitState?.isLoading).toBe(true); + }); +}); + +// ============================================================================ +// GIT_ERROR Tests +// ============================================================================ + +describe('GIT_ERROR', () => { + it('should set error message', () => { + const initialState = createStateWithGitState({ error: null }); + + const action: GitErrorAction = { + type: 'GIT_ERROR', + payload: 'Failed to fetch git status', + }; + + const newState = agentReducer(initialState, action); + + expect(newState.gitState?.error).toBe('Failed to fetch git status'); + expect(newState.gitState?.isLoading).toBe(false); + }); + + it('should clear error when payload is null', () => { + const initialState = createStateWithGitState({ error: 'Previous error' }); + + const action: GitErrorAction = { + type: 'GIT_ERROR', + payload: null, + }; + + const newState = agentReducer(initialState, action); + + expect(newState.gitState?.error).toBeNull(); + }); +}); diff --git a/web-ui/__tests__/types/git.test.ts b/web-ui/__tests__/types/git.test.ts new file mode 100644 index 00000000..13a4be16 --- /dev/null +++ b/web-ui/__tests__/types/git.test.ts @@ -0,0 +1,180 @@ +/** + * Git Types Test + * + * Tests for Git-related TypeScript type definitions. + * Ensures types are properly exported and can be used correctly. + */ + +import type { + GitBranch, + GitCommit, + GitStatus, + GitState, + BranchStatus, +} from '@/types/git'; + +describe('Git Types', () => { + describe('GitBranch', () => { + it('should allow valid branch data', () => { + const branch: GitBranch = { + id: 1, + branch_name: 'feature/auth-flow', + issue_id: 10, + status: 'active', + created_at: '2025-01-01T00:00:00Z', + }; + + expect(branch.id).toBe(1); + expect(branch.branch_name).toBe('feature/auth-flow'); + expect(branch.status).toBe('active'); + }); + + it('should allow optional merged_at and merge_commit', () => { + const mergedBranch: GitBranch = { + id: 2, + branch_name: 'feature/completed', + issue_id: 20, + status: 'merged', + created_at: '2025-01-01T00:00:00Z', + merged_at: '2025-01-02T00:00:00Z', + merge_commit: 'abc123def', + }; + + expect(mergedBranch.merged_at).toBe('2025-01-02T00:00:00Z'); + expect(mergedBranch.merge_commit).toBe('abc123def'); + }); + }); + + describe('GitCommit', () => { + it('should allow valid commit data', () => { + const commit: GitCommit = { + hash: 'abc123def456789', + short_hash: 'abc123d', + message: 'feat: Add user authentication', + author: 'Agent ', + timestamp: '2025-01-01T12:00:00Z', + }; + + expect(commit.hash).toBe('abc123def456789'); + expect(commit.short_hash).toBe('abc123d'); + expect(commit.message).toBe('feat: Add user authentication'); + }); + + it('should allow optional files_changed', () => { + const commitWithFiles: GitCommit = { + hash: 'abc123def456789', + short_hash: 'abc123d', + message: 'fix: Bug fix', + author: 'Agent ', + timestamp: '2025-01-01T12:00:00Z', + files_changed: 5, + }; + + expect(commitWithFiles.files_changed).toBe(5); + }); + }); + + describe('GitStatus', () => { + it('should allow valid git status data', () => { + const status: GitStatus = { + current_branch: 'main', + is_dirty: false, + modified_files: [], + untracked_files: [], + staged_files: [], + }; + + expect(status.current_branch).toBe('main'); + expect(status.is_dirty).toBe(false); + }); + + it('should allow files in all categories', () => { + const dirtyStatus: GitStatus = { + current_branch: 'feature/new', + is_dirty: true, + modified_files: ['src/app.ts', 'src/lib.ts'], + untracked_files: ['new-file.txt'], + staged_files: ['staged.ts'], + }; + + expect(dirtyStatus.modified_files).toHaveLength(2); + expect(dirtyStatus.untracked_files).toHaveLength(1); + expect(dirtyStatus.staged_files).toHaveLength(1); + }); + }); + + describe('GitState', () => { + it('should allow null status (loading state)', () => { + const state: GitState = { + status: null, + recentCommits: [], + branches: [], + isLoading: true, + error: null, + }; + + expect(state.status).toBeNull(); + expect(state.isLoading).toBe(true); + }); + + it('should allow populated state with all data', () => { + const state: GitState = { + status: { + current_branch: 'main', + is_dirty: false, + modified_files: [], + untracked_files: [], + staged_files: [], + }, + recentCommits: [ + { + hash: 'abc123', + short_hash: 'abc123', + message: 'Initial commit', + author: 'Developer', + timestamp: '2025-01-01T00:00:00Z', + }, + ], + branches: [ + { + id: 1, + branch_name: 'main', + issue_id: 0, + status: 'active', + created_at: '2025-01-01T00:00:00Z', + }, + ], + isLoading: false, + error: null, + }; + + expect(state.status?.current_branch).toBe('main'); + expect(state.recentCommits).toHaveLength(1); + expect(state.branches).toHaveLength(1); + }); + + it('should allow error state', () => { + const state: GitState = { + status: null, + recentCommits: [], + branches: [], + isLoading: false, + error: 'Failed to fetch git status', + }; + + expect(state.error).toBe('Failed to fetch git status'); + }); + }); + + describe('BranchStatus', () => { + it('should only allow valid status values', () => { + const activeStatus: BranchStatus = 'active'; + const mergedStatus: BranchStatus = 'merged'; + const abandonedStatus: BranchStatus = 'abandoned'; + + expect(activeStatus).toBe('active'); + expect(mergedStatus).toBe('merged'); + expect(abandonedStatus).toBe('abandoned'); + }); + }); +}); diff --git a/web-ui/src/api/git.ts b/web-ui/src/api/git.ts new file mode 100644 index 00000000..9267a70d --- /dev/null +++ b/web-ui/src/api/git.ts @@ -0,0 +1,161 @@ +/** + * Git API Client + * + * API functions for Git visualization features. + * Communicates with the Git REST API endpoints from ticket #270. + * + * @see codeframe/ui/routers/git.py for backend implementation + */ + +import { authFetch } from '@/lib/api-client'; +import type { + GitStatus, + GitCommit, + GitBranch, + BranchStatus, + BranchListResponse, + CommitListResponse, +} from '@/types/git'; + +/** + * Base API URL - defaults to localhost in development + */ +const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080'; + +// ============================================================================ +// Status Endpoints +// ============================================================================ + +/** + * Fetch git working tree status for a project + * + * @param projectId - Project ID + * @returns Git status including current branch and file states + * @throws Error if request fails or not authenticated + */ +export async function getGitStatus(projectId: number): Promise { + return authFetch( + `${API_BASE_URL}/api/projects/${projectId}/git/status` + ); +} + +// ============================================================================ +// Commit Endpoints +// ============================================================================ + +/** + * Options for fetching commits + */ +export interface GetCommitsOptions { + /** Branch name (default: current branch) */ + branch?: string; + /** Maximum commits to return (1-100, default 50) */ + limit?: number; +} + +/** + * Fetch git commits for a project + * + * @param projectId - Project ID + * @param options - Optional filters for branch and limit + * @returns Array of commits + * @throws Error if request fails or not authenticated + */ +export async function getCommits( + projectId: number, + options: GetCommitsOptions = {} +): Promise { + const params = new URLSearchParams(); + + if (options.branch) { + params.append('branch', options.branch); + } + + if (options.limit !== undefined) { + params.append('limit', options.limit.toString()); + } + + const queryString = params.toString(); + const url = `${API_BASE_URL}/api/projects/${projectId}/git/commits${ + queryString ? `?${queryString}` : '' + }`; + + const response = await authFetch(url); + return response.commits; +} + +// ============================================================================ +// Branch Endpoints +// ============================================================================ + +/** + * Fetch branches for a project + * + * @param projectId - Project ID + * @param status - Optional status filter (active, merged, abandoned) + * @returns Array of branches + * @throws Error if request fails or not authenticated + */ +export async function getBranches( + projectId: number, + status?: BranchStatus +): Promise { + const params = new URLSearchParams(); + + if (status) { + params.append('status', status); + } + + const queryString = params.toString(); + const url = `${API_BASE_URL}/api/projects/${projectId}/git/branches${ + queryString ? `?${queryString}` : '' + }`; + + const response = await authFetch(url); + return response.branches; +} + +/** + * Fetch a specific branch by name + * + * @param projectId - Project ID + * @param branchName - Branch name (should NOT be URL-encoded; encoding is handled internally) + * @returns Branch details + * @throws Error if request fails, not found, or not authenticated + */ +export async function getBranch( + projectId: number, + branchName: string +): Promise { + const encodedName = encodeURIComponent(branchName); + return authFetch( + `${API_BASE_URL}/api/projects/${projectId}/git/branches/${encodedName}` + ); +} + +// ============================================================================ +// Convenience Functions +// ============================================================================ + +/** + * Fetch current branch name for a project + * + * @param projectId - Project ID + * @returns Current branch name + * @throws Error if request fails or not authenticated + */ +export async function getCurrentBranch(projectId: number): Promise { + const status = await getGitStatus(projectId); + return status.current_branch; +} + +/** + * Fetch recent commits (last 10) for a project + * + * @param projectId - Project ID + * @returns Array of last 10 commits + * @throws Error if request fails or not authenticated + */ +export async function getRecentCommits(projectId: number): Promise { + return getCommits(projectId, { limit: 10 }); +} diff --git a/web-ui/src/components/Dashboard.tsx b/web-ui/src/components/Dashboard.tsx index 8494fb34..c2aaef17 100644 --- a/web-ui/src/components/Dashboard.tsx +++ b/web-ui/src/components/Dashboard.tsx @@ -37,6 +37,7 @@ import TaskStats from './tasks/TaskStats'; import PhaseProgress from './PhaseProgress'; import TaskList from './TaskList'; import TaskReview from './TaskReview'; +import { GitSection } from './git'; import { UserGroupIcon, Loading03Icon, @@ -552,6 +553,15 @@ export default function Dashboard({ projectId }: DashboardProps) { + {/* Git Visualization Section (Ticket #272) */} + {(projectData.phase === 'active' || + projectData.phase === 'review' || + projectData.phase === 'complete') && ( +
+ +
+ )} + {/* Chat Interface (cf-14.2) */} {showChat && (
diff --git a/web-ui/src/components/git/BranchList.tsx b/web-ui/src/components/git/BranchList.tsx new file mode 100644 index 00000000..48429475 --- /dev/null +++ b/web-ui/src/components/git/BranchList.tsx @@ -0,0 +1,178 @@ +/** + * BranchList Component + * + * Displays a list of git branches with status badges. + * Supports filtering by status and shows merge info for merged branches. + * + * Ticket: #272 - Git Visualization + */ + +'use client'; + +import { memo, useMemo } from 'react'; +import { GitCommitIcon, Loading03Icon, Alert02Icon } from '@hugeicons/react'; +import type { GitBranch, BranchStatus } from '@/types/git'; + +export interface BranchListProps { + /** Array of branches to display */ + branches: GitBranch[]; + /** Filter to specific status (optional) */ + filterStatus?: BranchStatus; + /** Loading state */ + isLoading?: boolean; + /** Error message */ + error?: string | null; + /** Optional callback for branch click */ + onBranchClick?: (branch: GitBranch) => void; +} + +/** + * Get badge styling based on branch status + */ +function getStatusStyles(status: BranchStatus): { bgClass: string; textClass: string } { + switch (status) { + case 'active': + return { bgClass: 'bg-primary/10', textClass: 'text-primary' }; + case 'merged': + return { bgClass: 'bg-secondary/10', textClass: 'text-secondary-foreground' }; + case 'abandoned': + return { bgClass: 'bg-muted', textClass: 'text-muted-foreground' }; + } +} + +/** + * Individual branch item + */ +interface BranchItemProps { + branch: GitBranch; + onClick?: () => void; +} + +const BranchItem = memo(function BranchItem({ branch, onClick }: BranchItemProps) { + const statusStyles = getStatusStyles(branch.status); + + return ( +
+
+ + + {branch.branch_name} + +
+
+ {branch.merge_commit && ( + + → {branch.merge_commit.slice(0, 7)} + + )} + + {branch.status} + +
+
+ ); +}); + +BranchItem.displayName = 'BranchItem'; + +/** + * BranchList - Shows list of git branches + */ +const BranchList = memo(function BranchList({ + branches, + filterStatus, + isLoading = false, + error = null, + onBranchClick, +}: BranchListProps) { + // Filter branches by status if provided + const displayedBranches = useMemo(() => { + if (!filterStatus) return branches; + return branches.filter((b) => b.status === filterStatus); + }, [branches, filterStatus]); + + // Loading state + if (isLoading) { + return ( +
+

+ + Branches +

+
+ + Loading branches... +
+
+ ); + } + + // Error state + if (error) { + return ( +
+

+ + Branches +

+
+ + {error} +
+
+ ); + } + + // Empty state + if (displayedBranches.length === 0) { + return ( +
+

+ + Branches +

+
+

No branches created yet

+

Branches will appear here as issues are worked on

+
+
+ ); + } + + return ( +
+

+ + Branches + + ({displayedBranches.length}) + +

+
+ {displayedBranches.map((branch) => ( + onBranchClick(branch) : undefined} + /> + ))} +
+
+ ); +}); + +BranchList.displayName = 'BranchList'; + +export default BranchList; diff --git a/web-ui/src/components/git/CommitHistory.tsx b/web-ui/src/components/git/CommitHistory.tsx new file mode 100644 index 00000000..d145fd4c --- /dev/null +++ b/web-ui/src/components/git/CommitHistory.tsx @@ -0,0 +1,189 @@ +/** + * CommitHistory Component + * + * Displays a list of recent git commits with message, hash, + * author, and file change count. + * + * Ticket: #272 - Git Visualization + */ + +'use client'; + +import { memo, useMemo } from 'react'; +import { GitCommitIcon, Loading03Icon, Alert02Icon } from '@hugeicons/react'; +import type { GitCommit } from '@/types/git'; + +export interface CommitHistoryProps { + /** Array of commits to display */ + commits: GitCommit[]; + /** Maximum number of commits to show (default: 10) */ + maxItems?: number; + /** Loading state */ + isLoading?: boolean; + /** Error message */ + error?: string | null; + /** Optional callback for commit click */ + onCommitClick?: (commit: GitCommit) => void; +} + +/** + * Format timestamp to relative time (e.g., "2 hours ago") + */ +function formatRelativeTime(timestamp: string): string { + const date = new Date(timestamp); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMins / 60); + const diffDays = Math.floor(diffHours / 24); + + if (diffMins < 1) return 'just now'; + if (diffMins < 60) return `${diffMins}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + if (diffDays < 7) return `${diffDays}d ago`; + return date.toLocaleDateString(); +} + +/** + * Individual commit item + */ +interface CommitItemProps { + commit: GitCommit; + onClick?: () => void; +} + +const CommitItem = memo(function CommitItem({ commit, onClick }: CommitItemProps) { + return ( +
+ +
+
+ + {commit.short_hash} + + {commit.files_changed !== undefined && ( + + {commit.files_changed} file{commit.files_changed !== 1 ? 's' : ''} + + )} +
+

+ {commit.message} +

+ +
+
+ ); +}); + +CommitItem.displayName = 'CommitItem'; + +/** + * CommitHistory - Shows list of recent commits + */ +const CommitHistory = memo(function CommitHistory({ + commits, + maxItems = 10, + isLoading = false, + error = null, + onCommitClick, +}: CommitHistoryProps) { + // Limit commits to maxItems + const displayedCommits = useMemo( + () => commits.slice(0, maxItems), + [commits, maxItems] + ); + + // Loading state + if (isLoading) { + return ( +
+

+ + Recent Commits +

+
+ + Loading commits... +
+
+ ); + } + + // Error state + if (error) { + return ( +
+

+ + Recent Commits +

+
+ + {error} +
+
+ ); + } + + // Empty state + if (displayedCommits.length === 0) { + return ( +
+

+ + Recent Commits +

+
+

No commits yet

+

Commits will appear here as work progresses

+
+
+ ); + } + + return ( +
+

+ + Recent Commits + + ({displayedCommits.length}) + +

+
+ {displayedCommits.map((commit) => ( + onCommitClick(commit) : undefined} + /> + ))} +
+ {commits.length > maxItems && ( +

+ Showing {maxItems} of {commits.length} commits +

+ )} +
+ ); +}); + +CommitHistory.displayName = 'CommitHistory'; + +export default CommitHistory; diff --git a/web-ui/src/components/git/GitBranchIndicator.tsx b/web-ui/src/components/git/GitBranchIndicator.tsx new file mode 100644 index 00000000..04830d5d --- /dev/null +++ b/web-ui/src/components/git/GitBranchIndicator.tsx @@ -0,0 +1,104 @@ +/** + * GitBranchIndicator Component + * + * Displays the current git branch name and status in a compact badge format. + * Shows dirty state indicator when there are uncommitted changes. + * + * Ticket: #272 - Git Visualization + */ + +'use client'; + +import { memo } from 'react'; +import { GitCommitIcon, Loading03Icon, Alert02Icon } from '@hugeicons/react'; +import type { GitStatus } from '@/types/git'; + +export interface GitBranchIndicatorProps { + /** Git status data (null when not loaded) */ + status: GitStatus | null; + /** Loading state */ + isLoading?: boolean; + /** Error message */ + error?: string | null; +} + +/** + * Calculate total file changes + */ +function getTotalChanges(status: GitStatus): number { + return ( + status.modified_files.length + + status.untracked_files.length + + status.staged_files.length + ); +} + +/** + * GitBranchIndicator - Shows current branch with status + */ +const GitBranchIndicator = memo(function GitBranchIndicator({ + status, + isLoading = false, + error = null, +}: GitBranchIndicatorProps) { + // Loading state + if (isLoading) { + return ( +
+ + Loading... +
+ ); + } + + // Error state + if (error) { + return ( +
+ + Git error +
+ ); + } + + // No status + if (!status) { + return null; + } + + const totalChanges = getTotalChanges(status); + const tooltipText = status.is_dirty + ? `${status.current_branch} (${totalChanges} uncommitted change${totalChanges !== 1 ? 's' : ''})` + : status.current_branch; + + return ( +
+ + + {status.current_branch} + + {status.is_dirty && ( + + )} +
+ ); +}); + +GitBranchIndicator.displayName = 'GitBranchIndicator'; + +export default GitBranchIndicator; diff --git a/web-ui/src/components/git/GitSection.tsx b/web-ui/src/components/git/GitSection.tsx new file mode 100644 index 00000000..107c0080 --- /dev/null +++ b/web-ui/src/components/git/GitSection.tsx @@ -0,0 +1,155 @@ +/** + * GitSection Component + * + * Container component that combines Git visualization components: + * - GitBranchIndicator (current branch) + * - CommitHistory (recent commits) + * - BranchList (all branches) + * + * Uses SWR for data fetching with automatic refresh. + * + * Ticket: #272 - Git Visualization + */ + +'use client'; + +import { memo } from 'react'; +import useSWR from 'swr'; +import { GitCommitIcon, Loading03Icon, Alert02Icon } from '@hugeicons/react'; +import GitBranchIndicator from './GitBranchIndicator'; +import CommitHistory from './CommitHistory'; +import BranchList from './BranchList'; +import { getGitStatus, getCommits, getBranches } from '@/api/git'; +import type { GitStatus, GitCommit, GitBranch } from '@/types/git'; + +export interface GitSectionProps { + /** Project ID to fetch Git data for */ + projectId: number; + /** Maximum number of commits to show (default: 5) */ + maxCommits?: number; + /** SWR refresh interval in ms (default: 30000) */ + refreshInterval?: number; +} + +/** + * GitSection - Dashboard section for Git visualization + */ +const GitSection = memo(function GitSection({ + projectId, + maxCommits = 5, + refreshInterval = 30000, +}: GitSectionProps) { + // Fetch git status + const { + data: status, + error: statusError, + isLoading: statusLoading, + } = useSWR( + `git-status-${projectId}`, + () => getGitStatus(projectId), + { refreshInterval } + ); + + // Fetch recent commits (cache key includes maxCommits to invalidate on limit change) + const { + data: commits, + error: commitsError, + isLoading: commitsLoading, + } = useSWR( + `git-commits-${projectId}-${maxCommits}`, + () => getCommits(projectId, { limit: maxCommits }), + { refreshInterval } + ); + + // Fetch branches + const { + data: branches, + error: branchesError, + isLoading: branchesLoading, + } = useSWR( + `git-branches-${projectId}`, + () => getBranches(projectId), + { refreshInterval } + ); + + // Combined loading state + const isLoading = statusLoading || commitsLoading || branchesLoading; + + // Combined error state + const hasError = statusError || commitsError || branchesError; + const errorMessage = statusError?.message || commitsError?.message || branchesError?.message; + + // Loading state + if (isLoading && !status && !commits && !branches) { + return ( +
+
+ + Loading Git data... +
+
+ ); + } + + // Error state + if (hasError && !status && !commits && !branches) { + return ( +
+
+ + + {errorMessage || 'Failed to load Git data'} + +
+
+ ); + } + + return ( +
+ {/* Section Header */} +
+
+ +

Code & Git

+
+ +
+ + {/* Content */} +
+ {/* Commits Section */} + + + {/* Branches Section */} + +
+
+ ); +}); + +GitSection.displayName = 'GitSection'; + +export default GitSection; diff --git a/web-ui/src/components/git/index.ts b/web-ui/src/components/git/index.ts new file mode 100644 index 00000000..616ccf27 --- /dev/null +++ b/web-ui/src/components/git/index.ts @@ -0,0 +1,17 @@ +/** + * Git Component Exports + * + * Central export point for all Git visualization components. + * Ticket: #272 - Git Visualization + */ + +export { default as GitBranchIndicator } from './GitBranchIndicator'; +export { default as CommitHistory } from './CommitHistory'; +export { default as BranchList } from './BranchList'; +export { default as GitSection } from './GitSection'; + +// Re-export prop types +export type { GitBranchIndicatorProps } from './GitBranchIndicator'; +export type { CommitHistoryProps } from './CommitHistory'; +export type { BranchListProps } from './BranchList'; +export type { GitSectionProps } from './GitSection'; diff --git a/web-ui/src/lib/websocketMessageMapper.ts b/web-ui/src/lib/websocketMessageMapper.ts index 7dda8203..185d3007 100644 --- a/web-ui/src/lib/websocketMessageMapper.ts +++ b/web-ui/src/lib/websocketMessageMapper.ts @@ -226,7 +226,6 @@ export function mapWebSocketMessageToAction( } case 'test_result': - case 'commit_created': case 'correction_attempt': { // These are special activity message types // Map them to activity updates @@ -244,6 +243,66 @@ export function mapWebSocketMessageToAction( }; } + // ======================================================================== + // Git Messages (Ticket #272) + // ======================================================================== + + case 'commit_created': { + const msg = message as WebSocketMessage; + + // Validate required fields - commits with empty hashes break downstream lookups + if (!msg.commit_hash || !msg.commit_message) { + if (process.env.NODE_ENV === 'development') { + console.warn('[WebSocketMapper] commit_created message missing required fields, skipping', msg); + } + return null; + } + + return { + type: 'COMMIT_CREATED', + payload: { + commit: { + hash: msg.commit_hash, + short_hash: msg.commit_hash.slice(0, 7), + message: msg.commit_message, + author: msg.agent || 'Agent', + timestamp: message.timestamp.toString(), + files_changed: Array.isArray(msg.files_changed) + ? msg.files_changed.length + : undefined, + }, + taskId: msg.task_id, + timestamp, + }, + }; + } + + case 'branch_created': { + const msg = message as WebSocketMessage; + + // Validate required fields - branches with id=0 or empty names break React keys + if (!msg.data?.branch_name || !msg.data?.id) { + if (process.env.NODE_ENV === 'development') { + console.warn('[WebSocketMapper] branch_created message missing required fields, skipping', msg); + } + return null; + } + + return { + type: 'BRANCH_CREATED', + payload: { + branch: { + id: msg.data.id, + branch_name: msg.data.branch_name, + issue_id: msg.data.issue_id ?? 0, + status: 'active' as const, + created_at: message.timestamp.toString(), + }, + timestamp, + }, + }; + } + case 'progress_update': { const msg = message as WebSocketMessage; return { diff --git a/web-ui/src/reducers/agentReducer.ts b/web-ui/src/reducers/agentReducer.ts index 3820378a..0500f0b5 100644 --- a/web-ui/src/reducers/agentReducer.ts +++ b/web-ui/src/reducers/agentReducer.ts @@ -10,6 +10,7 @@ */ import type { AgentState, AgentAction } from '@/types/agentState'; +import { INITIAL_GIT_STATE } from '@/types/git'; // ============================================================================ // Initial State @@ -27,6 +28,7 @@ export function getInitialState(): AgentState { projectProgress: null, wsConnected: false, lastSyncTimestamp: 0, + gitState: null, }; } @@ -374,6 +376,121 @@ export function agentReducer( break; } + // ======================================================================== + // Git Actions (Ticket #272) + // ======================================================================== + + case 'GIT_STATUS_LOADED': { + const { status } = action.payload; + const currentGitState = state.gitState ?? { ...INITIAL_GIT_STATE }; + + newState = { + ...state, + gitState: { + ...currentGitState, + status, + isLoading: false, + error: null, + }, + }; + break; + } + + case 'GIT_COMMITS_LOADED': { + const { commits } = action.payload; + const currentGitState = state.gitState ?? { ...INITIAL_GIT_STATE }; + + newState = { + ...state, + gitState: { + ...currentGitState, + recentCommits: commits, + isLoading: false, + error: null, + }, + }; + break; + } + + case 'GIT_BRANCHES_LOADED': { + const { branches } = action.payload; + const currentGitState = state.gitState ?? { ...INITIAL_GIT_STATE }; + + newState = { + ...state, + gitState: { + ...currentGitState, + branches, + isLoading: false, + error: null, + }, + }; + break; + } + + case 'COMMIT_CREATED': { + const { commit } = action.payload; + const currentGitState = state.gitState ?? { ...INITIAL_GIT_STATE }; + + // Defensive: ensure recentCommits is an array even if state was partially initialized + const currentCommits = currentGitState.recentCommits || []; + // Prepend new commit, keep only last 10 (FIFO) + const updatedCommits = [commit, ...currentCommits.slice(0, 9)]; + + newState = { + ...state, + gitState: { + ...currentGitState, + recentCommits: updatedCommits, + }, + }; + break; + } + + case 'BRANCH_CREATED': { + const { branch } = action.payload; + const currentGitState = state.gitState ?? { ...INITIAL_GIT_STATE }; + + // Defensive: ensure branches is an array even if state was partially initialized + const currentBranches = currentGitState.branches || []; + + newState = { + ...state, + gitState: { + ...currentGitState, + branches: [...currentBranches, branch], + }, + }; + break; + } + + case 'GIT_LOADING': { + const currentGitState = state.gitState ?? { ...INITIAL_GIT_STATE }; + + newState = { + ...state, + gitState: { + ...currentGitState, + isLoading: action.payload, + }, + }; + break; + } + + case 'GIT_ERROR': { + const currentGitState = state.gitState ?? { ...INITIAL_GIT_STATE }; + + newState = { + ...state, + gitState: { + ...currentGitState, + error: action.payload, + isLoading: false, + }, + }; + break; + } + // ======================================================================== // Default case - unknown action type // ======================================================================== diff --git a/web-ui/src/types/agentState.ts b/web-ui/src/types/agentState.ts index 0c3e7209..54023c69 100644 --- a/web-ui/src/types/agentState.ts +++ b/web-ui/src/types/agentState.ts @@ -6,8 +6,11 @@ * * Phase: 5.2 - Dashboard Multi-Agent State Management * Date: 2025-11-06 + * Updated: Git Visualization (Ticket #272) */ +import type { GitState, GitStatus, GitCommit, GitBranch } from './git'; + // ============================================================================ // Core Entity Types // ============================================================================ @@ -246,6 +249,7 @@ export interface AgentState { projectProgress: ProjectProgress | null; // Overall project progress wsConnected: boolean; // WebSocket connection status lastSyncTimestamp: number; // Unix ms of last full sync + gitState: GitState | null; // Git visualization state (null before first load) } // ============================================================================ @@ -387,6 +391,82 @@ export interface FullResyncAction { }; } +// ============================================================================ +// Git Actions (Ticket #272) +// ============================================================================ + +/** + * Load Git status from API + */ +export interface GitStatusLoadedAction { + type: 'GIT_STATUS_LOADED'; + payload: { + status: GitStatus; + timestamp: number; + }; +} + +/** + * Load Git commits from API + */ +export interface GitCommitsLoadedAction { + type: 'GIT_COMMITS_LOADED'; + payload: { + commits: GitCommit[]; + timestamp: number; + }; +} + +/** + * Load Git branches from API + */ +export interface GitBranchesLoadedAction { + type: 'GIT_BRANCHES_LOADED'; + payload: { + branches: GitBranch[]; + timestamp: number; + }; +} + +/** + * Handle new commit created via WebSocket + */ +export interface CommitCreatedAction { + type: 'COMMIT_CREATED'; + payload: { + commit: GitCommit; + taskId?: number; + timestamp: number; + }; +} + +/** + * Handle branch created via WebSocket + */ +export interface BranchCreatedAction { + type: 'BRANCH_CREATED'; + payload: { + branch: GitBranch; + timestamp: number; + }; +} + +/** + * Set Git loading state + */ +export interface GitLoadingAction { + type: 'GIT_LOADING'; + payload: boolean; +} + +/** + * Set Git error state + */ +export interface GitErrorAction { + type: 'GIT_ERROR'; + payload: string | null; +} + /** * Discriminated union of all possible reducer actions */ @@ -403,7 +483,15 @@ export type AgentAction = | ActivityAddedAction | ProgressUpdatedAction | WebSocketConnectedAction - | FullResyncAction; + | FullResyncAction + // Git Actions (Ticket #272) + | GitStatusLoadedAction + | GitCommitsLoadedAction + | GitBranchesLoadedAction + | CommitCreatedAction + | BranchCreatedAction + | GitLoadingAction + | GitErrorAction; // ============================================================================ // Utility Types diff --git a/web-ui/src/types/git.ts b/web-ui/src/types/git.ts new file mode 100644 index 00000000..5370cbad --- /dev/null +++ b/web-ui/src/types/git.ts @@ -0,0 +1,133 @@ +/** + * Git Type Definitions for CodeFRAME UI + * + * TypeScript interfaces for Git visualization components. + * These types match the backend API responses from the Git router. + * + * @see codeframe/ui/routers/git.py for API response models + */ + +// ============================================================================ +// Branch Types +// ============================================================================ + +/** + * Valid branch status values + */ +export type BranchStatus = 'active' | 'merged' | 'abandoned'; + +/** + * Git branch entity matching BranchResponse from backend + */ +export interface GitBranch { + id: number; + branch_name: string; + issue_id: number; + status: BranchStatus; + created_at: string; + merged_at?: string; + merge_commit?: string; +} + +// ============================================================================ +// Commit Types +// ============================================================================ + +/** + * Git commit entity matching CommitListItem from backend + */ +export interface GitCommit { + hash: string; + short_hash: string; + message: string; + author: string; + timestamp: string; + files_changed?: number; +} + +// ============================================================================ +// Status Types +// ============================================================================ + +/** + * Git working tree status matching GitStatusResponse from backend + */ +export interface GitStatus { + current_branch: string; + is_dirty: boolean; + modified_files: string[]; + untracked_files: string[]; + staged_files: string[]; +} + +// ============================================================================ +// State Management Types +// ============================================================================ + +/** + * Git state for the Dashboard context + * Used by GitSection and related components + */ +export interface GitState { + /** Current git status (null when loading or on error) */ + status: GitStatus | null; + + /** Recent commits (limited to last 10) */ + recentCommits: GitCommit[]; + + /** All branches for the project */ + branches: GitBranch[]; + + /** Loading state indicator */ + isLoading: boolean; + + /** Error message (null when no error) */ + error: string | null; +} + +// ============================================================================ +// API Response Types +// ============================================================================ + +/** + * Response from GET /api/projects/{id}/git/branches + */ +export interface BranchListResponse { + branches: GitBranch[]; +} + +/** + * Response from GET /api/projects/{id}/git/commits + */ +export interface CommitListResponse { + commits: GitCommit[]; +} + +// ============================================================================ +// Utility Types +// ============================================================================ + +/** + * Initial/empty Git state for initialization + */ +export const INITIAL_GIT_STATE: GitState = { + status: null, + recentCommits: [], + branches: [], + isLoading: false, + error: null, +}; + +/** + * Type guard to check if branch is active + */ +export function isBranchActive(branch: GitBranch): boolean { + return branch.status === 'active'; +} + +/** + * Type guard to check if branch is merged + */ +export function isBranchMerged(branch: GitBranch): boolean { + return branch.status === 'merged'; +} diff --git a/web-ui/src/types/index.ts b/web-ui/src/types/index.ts index 5faff2e4..86ed0d2c 100644 --- a/web-ui/src/types/index.ts +++ b/web-ui/src/types/index.ts @@ -120,7 +120,8 @@ export type WebSocketMessageType = | 'agent_retired' // Sprint 4 | 'task_assigned' // Sprint 4 | 'task_blocked' // Sprint 4 - | 'task_unblocked'; // Sprint 4 + | 'task_unblocked' // Sprint 4 + | 'branch_created'; // Ticket #272 - Git Visualization export interface WebSocketMessage { type: WebSocketMessageType; @@ -242,4 +243,16 @@ export type { GetAgentsForProjectParams, GetProjectsForAgentParams, UnassignAgentParams, -} from './agentAssignment'; \ No newline at end of file +} from './agentAssignment'; + +// Re-export Git types (Git Visualization - Ticket #272) +export type { + GitBranch, + GitCommit, + GitStatus, + GitState, + BranchStatus, + BranchListResponse, + CommitListResponse, +} from './git'; +export { INITIAL_GIT_STATE, isBranchActive, isBranchMerged } from './git'; \ No newline at end of file diff --git a/web-ui/test-utils/agentState.fixture.ts b/web-ui/test-utils/agentState.fixture.ts index 2d22374d..ed36b8f6 100644 --- a/web-ui/test-utils/agentState.fixture.ts +++ b/web-ui/test-utils/agentState.fixture.ts @@ -85,6 +85,7 @@ export function createInitialAgentState(overrides?: Partial): AgentS projectProgress: null, wsConnected: false, lastSyncTimestamp: 0, + gitState: null, ...overrides, }; } @@ -157,6 +158,7 @@ export function createPopulatedAgentState(): AgentState { }), wsConnected: true, lastSyncTimestamp: Date.now(), + gitState: null, }; }