diff --git a/apps/frontend/src/renderer/components/GitHubIssues.tsx b/apps/frontend/src/renderer/components/GitHubIssues.tsx index 2a107d56..410b60d4 100644 --- a/apps/frontend/src/renderer/components/GitHubIssues.tsx +++ b/apps/frontend/src/renderer/components/GitHubIssues.tsx @@ -174,6 +174,8 @@ export function GitHubIssues({ onOpenSettings, onNavigateToTask }: GitHubIssuesP onSelectIssue={selectIssue} onInvestigate={handleInvestigate} onLoadMore={!isSearchActive ? handleLoadMore : undefined} + onRetry={handleRefresh} + onOpenSettings={onOpenSettings} /> diff --git a/apps/frontend/src/renderer/components/github-issues/components/GitHubErrorDisplay.tsx b/apps/frontend/src/renderer/components/github-issues/components/GitHubErrorDisplay.tsx new file mode 100644 index 00000000..b2db1078 --- /dev/null +++ b/apps/frontend/src/renderer/components/github-issues/components/GitHubErrorDisplay.tsx @@ -0,0 +1,371 @@ +import { useState, useEffect, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + AlertTriangle, + Clock, + Key, + Shield, + WifiOff, + SearchX, + RefreshCw, + Settings2, +} from 'lucide-react'; +import { Button } from '../../ui/button'; +import { Card, CardContent } from '../../ui/card'; +import { cn } from '../../../lib/utils'; +import { parseGitHubError } from '../utils/github-error-parser'; +import type { GitHubErrorInfo, GitHubErrorType } from '../types'; + +/** + * Props for the GitHubErrorDisplay component. + */ +export interface GitHubErrorDisplayProps { + /** Raw error string or pre-parsed GitHubErrorInfo */ + error: string | GitHubErrorInfo | null; + /** Callback when user clicks retry button */ + onRetry?: () => void; + /** Callback when user clicks settings button */ + onOpenSettings?: () => void; + /** Additional CSS classes */ + className?: string; + /** Whether to show as compact inline error (vs full-width card) */ + compact?: boolean; +} + +/** + * Configuration for each error type: icon, color, title key. + */ +const ERROR_CONFIG: Record< + GitHubErrorType, + { + icon: React.ComponentType<{ className?: string }>; + titleKey: string; + iconColorClass: string; + } +> = { + rate_limit: { + icon: Clock, + titleKey: 'githubErrors.rateLimitTitle', + iconColorClass: 'text-warning', + }, + auth: { + icon: Key, + titleKey: 'githubErrors.authTitle', + iconColorClass: 'text-destructive', + }, + permission: { + icon: Shield, + titleKey: 'githubErrors.permissionTitle', + iconColorClass: 'text-destructive', + }, + not_found: { + icon: SearchX, + titleKey: 'githubErrors.notFoundTitle', + iconColorClass: 'text-muted-foreground', + }, + network: { + icon: WifiOff, + titleKey: 'githubErrors.networkTitle', + iconColorClass: 'text-warning', + }, + unknown: { + icon: AlertTriangle, + titleKey: 'githubErrors.unknownTitle', + iconColorClass: 'text-destructive', + }, +}; + +/** + * Base message keys for each error type. + * Hoisted to module scope to avoid recreation on every function call. + */ +const BASE_MESSAGE_KEYS: Record = { + rate_limit: 'githubErrors.rateLimitMessage', + auth: 'githubErrors.authMessage', + permission: 'githubErrors.permissionMessage', + not_found: 'githubErrors.notFoundMessage', + network: 'githubErrors.networkMessage', + unknown: 'githubErrors.unknownMessage', +}; + +/** + * Countdown time components for i18n-friendly formatting. + */ +interface CountdownComponents { + hours: number; + minutes: number; + seconds: number; +} + +/** + * Calculate countdown time components from reset time. + * Returns numeric values for i18n-friendly formatting in the component. + */ +function getCountdownComponents(resetTime: Date): CountdownComponents | null { + const now = new Date(); + const diffMs = resetTime.getTime() - now.getTime(); + + if (diffMs <= 0) { + return null; + } + + const diffSecs = Math.floor(diffMs / 1000); + const diffMins = Math.floor(diffSecs / 60); + const diffHours = Math.floor(diffMins / 60); + + return { + hours: diffHours, + minutes: diffHours > 0 ? diffMins % 60 : diffMins, + seconds: diffSecs % 60, + }; +} + +/** + * Select the most specific message key based on available metadata. + * Pure function extracted to module scope to avoid recreation on each render. + * @param info - The error info object + * @param rateLimitDiffMs - Pre-computed time difference in milliseconds (avoids dual calculation) + */ +function getMessageKey(info: GitHubErrorInfo, rateLimitDiffMs?: number): string { + if (info.type === 'rate_limit' && rateLimitDiffMs !== undefined && rateLimitDiffMs > 0) { + const diffMins = Math.ceil(rateLimitDiffMs / 60000); + return diffMins >= 60 + ? 'githubErrors.rateLimitMessageHours' + : 'githubErrors.rateLimitMessageMinutes'; + } + if (info.type === 'permission' && info.requiredScopes && info.requiredScopes.length > 0) { + return 'githubErrors.permissionMessageScopes'; + } + return BASE_MESSAGE_KEYS[info.type]; +} + +/** + * Component that displays GitHub API errors with appropriate icons, + * messages, and action buttons based on error type. + * + * @example + * ```tsx + * // With raw error string + * + * + * // With pre-parsed error info + * + * ``` + */ +export function GitHubErrorDisplay({ + error, + onRetry, + onOpenSettings, + className, + compact = false, +}: GitHubErrorDisplayProps) { + const { t } = useTranslation('common'); + + // Parse error if it's a string, otherwise use the provided GitHubErrorInfo + // Memoize to prevent useEffect churn from new Date references on each render + const errorInfo: GitHubErrorInfo = useMemo( + () => + typeof error === 'string' || error === null + ? parseGitHubError(error) + : error, + [error] + ); + + // State for rate limit countdown components + const [countdownComponents, setCountdownComponents] = useState(() => + errorInfo.rateLimitResetTime + ? getCountdownComponents(errorInfo.rateLimitResetTime) + : null + ); + + // Update countdown every second for rate limit errors + // Extract timestamp for stable useEffect dependency (avoids optional chaining in deps) + const resetTimeMs = errorInfo.rateLimitResetTime?.getTime(); + + useEffect(() => { + if (errorInfo.type !== 'rate_limit' || !errorInfo.rateLimitResetTime) { + // Clear stale countdown state when error type changes away from rate_limit + setCountdownComponents(null); + return; + } + + const resetTime = errorInfo.rateLimitResetTime; + let intervalId: ReturnType | undefined; + + const updateCountdown = () => { + const components = getCountdownComponents(resetTime); + setCountdownComponents(components); + // Stop the interval when countdown expires + if (!components && intervalId) { + clearInterval(intervalId); + intervalId = undefined; + } + }; + + // Update immediately + updateCountdown(); + + // Only set interval if countdown is still active + if (getCountdownComponents(resetTime)) { + intervalId = setInterval(updateCountdown, 1000); + } + + // Cleanup on unmount or when error changes + return () => { + if (intervalId) clearInterval(intervalId); + }; + }, [errorInfo.type, resetTimeMs]); + + // Format countdown using i18n + const formatCountdownDisplay = (components: CountdownComponents | null): string => { + if (!components) return ''; + if (components.hours > 0) { + return t('githubErrors.countdownHoursMinutes', { + hours: components.hours, + minutes: components.minutes, + }); + } + return t('githubErrors.countdownMinutesSeconds', { + minutes: components.minutes, + seconds: components.seconds, + }); + }; + + // Get configuration for this error type + const config = ERROR_CONFIG[errorInfo.type]; + const Icon = config.icon; + + // Determine which actions to show + const showRetry = ['rate_limit', 'network', 'unknown'].includes(errorInfo.type); + const showSettings = ['auth', 'permission'].includes(errorInfo.type); + const isRateLimitExpired = + errorInfo.type === 'rate_limit' && + errorInfo.rateLimitResetTime && + new Date() >= errorInfo.rateLimitResetTime; + + // Don't render if no error + if (!error) return null; + + // Compute time remaining once for both message key selection and translation + const rateLimitDiffMs = errorInfo.rateLimitResetTime + ? errorInfo.rateLimitResetTime.getTime() - Date.now() + : undefined; + + // Get the translated message with appropriate interpolation values + const messageKey = getMessageKey(errorInfo, rateLimitDiffMs); + // Only pass positive minutes/hours values to avoid stale negative/zero values + const rawMinutes = rateLimitDiffMs ? Math.ceil(rateLimitDiffMs / 60000) : undefined; + const minutes = rawMinutes && rawMinutes > 0 ? rawMinutes : undefined; + const hours = minutes ? Math.ceil(minutes / 60) : undefined; + + const errorMessage = t(messageKey, { + defaultValue: errorInfo.message, + minutes, + hours, + scopes: errorInfo.requiredScopes?.join(', '), + }); + + // Compact variant for inline display + if (compact) { + return ( +
+ + + {t(config.titleKey)} + + {showRetry && onRetry && ( + + )} + {showSettings && onOpenSettings && ( + + )} +
+ ); + } + + // Full card variant for blocking errors + return ( + + +
+
+ +
+
+

+ {t(config.titleKey)} +

+

{errorMessage}

+ {/* Rate limit countdown display */} + {errorInfo.type === 'rate_limit' && countdownComponents && ( +

+ {t('githubErrors.resetsIn', { time: formatCountdownDisplay(countdownComponents) })} +

+ )} + {/* Rate limit expired - show retry prompt */} + {isRateLimitExpired && ( +

+ {t('githubErrors.rateLimitExpired')} +

+ )} + {/* Required scopes for permission errors */} + {errorInfo.requiredScopes && errorInfo.requiredScopes.length > 0 && ( +

+ {t('githubErrors.requiredScopes')}:{' '} + + {errorInfo.requiredScopes.join(', ')} + +

+ )} +
+ {/* Action buttons */} +
+ {showRetry && onRetry && ( + + )} + {showSettings && onOpenSettings && ( + + )} +
+
+
+
+ ); +} diff --git a/apps/frontend/src/renderer/components/github-issues/components/IssueList.tsx b/apps/frontend/src/renderer/components/github-issues/components/IssueList.tsx index b21b877c..d6586889 100644 --- a/apps/frontend/src/renderer/components/github-issues/components/IssueList.tsx +++ b/apps/frontend/src/renderer/components/github-issues/components/IssueList.tsx @@ -1,8 +1,9 @@ import { useRef, useEffect, useCallback, useState } from 'react'; -import { Loader2, AlertCircle } from 'lucide-react'; +import { Loader2 } from 'lucide-react'; import { ScrollArea } from '../../ui/scroll-area'; import { IssueListItem } from './IssueListItem'; import { EmptyState } from './EmptyStates'; +import { GitHubErrorDisplay } from './GitHubErrorDisplay'; import type { IssueListProps } from '../types'; import { useTranslation } from 'react-i18next'; @@ -15,7 +16,9 @@ export function IssueList({ error, onSelectIssue, onInvestigate, - onLoadMore + onLoadMore, + onRetry, + onOpenSettings }: IssueListProps) { const { t } = useTranslation('common'); const loadMoreTriggerRef = useRef(null); @@ -50,12 +53,12 @@ export function IssueList({ // Load-more errors are shown inline near the load-more trigger if (error && issues.length === 0) { return ( -
-
- - {error} -
-
+ ); } @@ -85,15 +88,18 @@ export function IssueList({ ))} {/* Load more trigger / Loading indicator */} + {/* Inline error for load-more failures (visible even when onLoadMore is undefined during search) */} + {error && issues.length > 0 && ( + + )} {onLoadMore && (
- {/* Inline error for load-more failures (when issues are already loaded) */} - {error && issues.length > 0 && ( -
- - {error} -
- )} {isLoadingMore ? (
diff --git a/apps/frontend/src/renderer/components/github-issues/components/__tests__/GitHubErrorDisplay.test.tsx b/apps/frontend/src/renderer/components/github-issues/components/__tests__/GitHubErrorDisplay.test.tsx new file mode 100644 index 00000000..092bc042 --- /dev/null +++ b/apps/frontend/src/renderer/components/github-issues/components/__tests__/GitHubErrorDisplay.test.tsx @@ -0,0 +1,500 @@ +/** + * @vitest-environment jsdom + */ +/** + * Unit tests for GitHubErrorDisplay component. + * Tests error display, icon rendering, button visibility, and countdown functionality. + */ +import { describe, it, expect, vi } from 'vitest'; +import '@testing-library/jest-dom/vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { GitHubErrorDisplay } from '../GitHubErrorDisplay'; +import type { GitHubErrorInfo } from '../../types'; + +// Mock react-i18next +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, options?: Record) => { + const translations: Record = { + 'githubErrors.rateLimitTitle': 'GitHub Rate Limit Reached', + 'githubErrors.authTitle': 'GitHub Authentication Required', + 'githubErrors.permissionTitle': 'GitHub Permission Denied', + 'githubErrors.notFoundTitle': 'GitHub Resource Not Found', + 'githubErrors.networkTitle': 'GitHub Connection Error', + 'githubErrors.unknownTitle': 'GitHub Error', + 'githubErrors.rateLimitMessage': 'GitHub API rate limit reached. Please wait a moment before trying again.', + 'githubErrors.rateLimitMessageMinutes': `GitHub API rate limit reached. Please wait ${options?.minutes ?? 'X'} minute(s) before trying again.`, + 'githubErrors.rateLimitMessageHours': `GitHub API rate limit reached. Rate limit resets in approximately ${options?.hours ?? 'X'} hour(s).`, + 'githubErrors.authMessage': 'GitHub authentication failed. Please check your GitHub token in Settings.', + 'githubErrors.permissionMessage': 'GitHub permission denied. Your token may not have the required access.', + 'githubErrors.permissionMessageScopes': `GitHub permission denied. Your token is missing required scopes: ${options?.scopes ?? ''}. Please update your GitHub token in Settings.`, + 'githubErrors.notFoundMessage': 'The requested GitHub resource was not found.', + 'githubErrors.networkMessage': 'Unable to connect to GitHub. Please check your internet connection.', + 'githubErrors.unknownMessage': 'An unexpected error occurred while communicating with GitHub.', + 'githubErrors.resetsIn': options?.time ? `Resets in ${options.time as string}` : 'Resets in', + 'githubErrors.countdownHoursMinutes': `${options?.hours ?? 0}h ${options?.minutes ?? 0}m`, + 'githubErrors.countdownMinutesSeconds': `${options?.minutes ?? 0}m ${options?.seconds ?? 0}s`, + 'githubErrors.rateLimitExpired': 'Rate limit has reset. You can retry now.', + 'githubErrors.requiredScopes': 'Required scopes', + 'buttons.retry': 'Retry', + 'actions.settings': 'Settings', + }; + return translations[key] || key; + }, + }), +})); + +// Helper to create mock GitHubErrorInfo +function createMockErrorInfo( + type: GitHubErrorInfo['type'], + overrides: Partial = {} +): GitHubErrorInfo { + const defaults: Record = { + rate_limit: { + type: 'rate_limit', + message: 'GitHub API rate limit reached. Please wait a moment before trying again.', + statusCode: 403, + }, + auth: { + type: 'auth', + message: 'GitHub authentication failed. Please check your GitHub token in Settings.', + statusCode: 401, + }, + permission: { + type: 'permission', + message: 'GitHub permission denied. Your token may not have the required access.', + statusCode: 403, + }, + not_found: { + type: 'not_found', + message: 'The requested GitHub resource was not found.', + statusCode: 404, + }, + network: { + type: 'network', + message: 'Unable to connect to GitHub. Please check your internet connection.', + }, + unknown: { + type: 'unknown', + message: 'An unexpected error occurred while communicating with GitHub.', + }, + }; + + return { ...defaults[type], ...overrides }; +} + +describe('GitHubErrorDisplay', () => { + describe('rendering null/empty states', () => { + it('should render nothing when error is null', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('should render nothing when error is an empty string', () => { + // Empty string is falsy, so component should return null + const { container } = render( + + ); + expect(container.firstChild).toBeNull(); + }); + }); + + describe('rendering with string error', () => { + it('should render error display when error is a string', () => { + render(); + + // Should show the auth title (parsed from the error) + expect(screen.getByText('GitHub Authentication Required')).toBeInTheDocument(); + }); + + it('should render error display for rate limit string error', () => { + render(); + + expect(screen.getByText('GitHub Rate Limit Reached')).toBeInTheDocument(); + }); + }); + + describe('rendering with GitHubErrorInfo object', () => { + it('should render rate_limit error correctly', () => { + const errorInfo = createMockErrorInfo('rate_limit'); + render(); + + expect(screen.getByText('GitHub Rate Limit Reached')).toBeInTheDocument(); + expect( + screen.getByText(/GitHub API rate limit reached/) + ).toBeInTheDocument(); + }); + + it('should render auth error correctly', () => { + const errorInfo = createMockErrorInfo('auth'); + render(); + + expect(screen.getByText('GitHub Authentication Required')).toBeInTheDocument(); + expect(screen.getByText(/authentication failed/)).toBeInTheDocument(); + }); + + it('should render permission error correctly', () => { + const errorInfo = createMockErrorInfo('permission', { + requiredScopes: ['repo', 'workflow'], + }); + render(); + + expect(screen.getByText('GitHub Permission Denied')).toBeInTheDocument(); + // Check that permission message is rendered + expect(screen.getByText(/Your token is missing required scopes/)).toBeInTheDocument(); + // Should show required scopes in the code element + expect(screen.getByText('repo, workflow')).toBeInTheDocument(); + }); + + it('should render not_found error correctly', () => { + const errorInfo = createMockErrorInfo('not_found'); + render(); + + expect(screen.getByText('GitHub Resource Not Found')).toBeInTheDocument(); + expect(screen.getByText(/not found/)).toBeInTheDocument(); + }); + + it('should render network error correctly', () => { + const errorInfo = createMockErrorInfo('network'); + render(); + + expect(screen.getByText('GitHub Connection Error')).toBeInTheDocument(); + expect(screen.getByText(/Unable to connect/)).toBeInTheDocument(); + }); + + it('should render unknown error correctly', () => { + const errorInfo = createMockErrorInfo('unknown'); + render(); + + expect(screen.getByText('GitHub Error')).toBeInTheDocument(); + expect(screen.getByText(/unexpected error/)).toBeInTheDocument(); + }); + }); + + describe('compact mode', () => { + it('should render compact variant when compact=true', () => { + const errorInfo = createMockErrorInfo('rate_limit'); + render(); + + // In compact mode, the title is in a smaller span + expect(screen.getByText('GitHub Rate Limit Reached')).toBeInTheDocument(); + // Should not render the card structure (no centered layout) + expect(screen.queryByRole('heading', { level: 3 })).not.toBeInTheDocument(); + }); + + it('should show retry button in compact mode for rate_limit errors', () => { + const errorInfo = createMockErrorInfo('rate_limit'); + const onRetry = vi.fn(); + render(); + + const retryButton = screen.getByRole('button', { name: /retry/i }); + expect(retryButton).toBeInTheDocument(); + + fireEvent.click(retryButton); + expect(onRetry).toHaveBeenCalledTimes(1); + }); + + it('should show settings button in compact mode for auth errors', () => { + const errorInfo = createMockErrorInfo('auth'); + const onOpenSettings = vi.fn(); + render(); + + const settingsButton = screen.getByRole('button', { name: /settings/i }); + expect(settingsButton).toBeInTheDocument(); + + fireEvent.click(settingsButton); + expect(onOpenSettings).toHaveBeenCalledTimes(1); + }); + }); + + describe('full card mode (default)', () => { + it('should render card structure by default', () => { + const errorInfo = createMockErrorInfo('rate_limit'); + render(); + + // Should render heading + expect(screen.getByRole('heading', { level: 3 })).toBeInTheDocument(); + }); + + it('should show retry button for rate_limit errors with onRetry callback', () => { + const errorInfo = createMockErrorInfo('rate_limit'); + const onRetry = vi.fn(); + render(); + + const retryButton = screen.getByRole('button', { name: /retry/i }); + expect(retryButton).toBeInTheDocument(); + + fireEvent.click(retryButton); + expect(onRetry).toHaveBeenCalledTimes(1); + }); + + it('should show retry button for network errors', () => { + const errorInfo = createMockErrorInfo('network'); + const onRetry = vi.fn(); + render(); + + const retryButton = screen.getByRole('button', { name: /retry/i }); + expect(retryButton).toBeInTheDocument(); + }); + + it('should show retry button for unknown errors', () => { + const errorInfo = createMockErrorInfo('unknown'); + const onRetry = vi.fn(); + render(); + + const retryButton = screen.getByRole('button', { name: /retry/i }); + expect(retryButton).toBeInTheDocument(); + }); + + it('should NOT show retry button for auth errors', () => { + const errorInfo = createMockErrorInfo('auth'); + const onRetry = vi.fn(); + render(); + + expect(screen.queryByRole('button', { name: /retry/i })).not.toBeInTheDocument(); + }); + + it('should NOT show retry button for permission errors', () => { + const errorInfo = createMockErrorInfo('permission'); + const onRetry = vi.fn(); + render(); + + expect(screen.queryByRole('button', { name: /retry/i })).not.toBeInTheDocument(); + }); + + it('should NOT show retry button for not_found errors', () => { + const errorInfo = createMockErrorInfo('not_found'); + const onRetry = vi.fn(); + render(); + + expect(screen.queryByRole('button', { name: /retry/i })).not.toBeInTheDocument(); + }); + + it('should show settings button for auth errors with onOpenSettings callback', () => { + const errorInfo = createMockErrorInfo('auth'); + const onOpenSettings = vi.fn(); + render(); + + const settingsButton = screen.getByRole('button', { name: /settings/i }); + expect(settingsButton).toBeInTheDocument(); + + fireEvent.click(settingsButton); + expect(onOpenSettings).toHaveBeenCalledTimes(1); + }); + + it('should show settings button for permission errors', () => { + const errorInfo = createMockErrorInfo('permission'); + const onOpenSettings = vi.fn(); + render(); + + const settingsButton = screen.getByRole('button', { name: /settings/i }); + expect(settingsButton).toBeInTheDocument(); + }); + + it('should NOT show settings button for rate_limit errors', () => { + const errorInfo = createMockErrorInfo('rate_limit'); + const onOpenSettings = vi.fn(); + render(); + + expect(screen.queryByRole('button', { name: /settings/i })).not.toBeInTheDocument(); + }); + + it('should NOT show settings button for network errors', () => { + const errorInfo = createMockErrorInfo('network'); + const onOpenSettings = vi.fn(); + render(); + + expect(screen.queryByRole('button', { name: /settings/i })).not.toBeInTheDocument(); + }); + }); + + describe('rate limit countdown', () => { + it('should display countdown for rate limit errors with reset time', () => { + // Set reset time 5 minutes in the future + const resetTime = new Date(Date.now() + 5 * 60 * 1000); + const errorInfo = createMockErrorInfo('rate_limit', { + rateLimitResetTime: resetTime, + }); + + render(); + + // Should show countdown in "Xm Ys" format (e.g., "4m 59s" or "5m 0s") + expect(screen.getByText(/Resets in \d+m \d+s/)).toBeInTheDocument(); + }); + + it('should set up interval to update countdown', () => { + vi.useFakeTimers(); + const resetTime = new Date(Date.now() + 2 * 60 * 1000); + const errorInfo = createMockErrorInfo('rate_limit', { + rateLimitResetTime: resetTime, + }); + + render(); + + // Initial countdown should be displayed + expect(screen.getByText(/Resets in/)).toBeInTheDocument(); + + // Verify interval is running by checking timers + const timerCount = vi.getTimerCount(); + expect(timerCount).toBe(1); // One interval should be running + + // Advance time and verify interval still fires + vi.advanceTimersByTime(1000); + expect(screen.getByText(/Resets in/)).toBeInTheDocument(); + + vi.useRealTimers(); + }); + + it('should NOT show countdown for non-rate-limit errors', () => { + const errorInfo = createMockErrorInfo('auth'); + render(); + + expect(screen.queryByText(/Resets in/)).not.toBeInTheDocument(); + }); + + it('should show rate limit expired message when reset time has passed', () => { + // Set reset time in the past + const resetTime = new Date(Date.now() - 1000); + const errorInfo = createMockErrorInfo('rate_limit', { + rateLimitResetTime: resetTime, + }); + + render(); + + expect( + screen.getByText('Rate limit has reset. You can retry now.') + ).toBeInTheDocument(); + }); + + it('should cleanup interval on unmount', () => { + vi.useFakeTimers(); + const clearIntervalSpy = vi.spyOn(global, 'clearInterval'); + + const resetTime = new Date(Date.now() + 5 * 60 * 1000); + const errorInfo = createMockErrorInfo('rate_limit', { + rateLimitResetTime: resetTime, + }); + + const { unmount } = render(); + + // Verify the countdown was rendered + expect(screen.getByText(/Resets in/)).toBeInTheDocument(); + + // Unmount and verify clearInterval was called + unmount(); + expect(clearIntervalSpy).toHaveBeenCalled(); + + clearIntervalSpy.mockRestore(); + vi.useRealTimers(); + }); + }); + + describe('required scopes display', () => { + it('should display required scopes for permission errors', () => { + const errorInfo = createMockErrorInfo('permission', { + requiredScopes: ['repo', 'read:org', 'workflow'], + }); + + render(); + + expect(screen.getByText('Required scopes:')).toBeInTheDocument(); + // The scopes appear in a code element + expect(screen.getByText('repo, read:org, workflow')).toBeInTheDocument(); + }); + + it('should NOT display scopes section when no scopes are provided', () => { + const errorInfo = createMockErrorInfo('permission', { + requiredScopes: undefined, + }); + + render(); + + expect(screen.queryByText('Required scopes:')).not.toBeInTheDocument(); + }); + + it('should NOT display scopes section when scopes array is empty', () => { + const errorInfo = createMockErrorInfo('permission', { + requiredScopes: [], + }); + + render(); + + expect(screen.queryByText('Required scopes:')).not.toBeInTheDocument(); + }); + }); + + describe('className prop', () => { + it('should apply custom className in full card mode', () => { + const errorInfo = createMockErrorInfo('rate_limit'); + const { container } = render( + + ); + + expect(container.firstChild).toHaveClass('custom-class'); + }); + + it('should apply custom className in compact mode', () => { + const errorInfo = createMockErrorInfo('rate_limit'); + const { container } = render( + + ); + + expect(container.firstChild).toHaveClass('custom-compact-class'); + }); + }); + + describe('callback stability', () => { + it('should not call onRetry on initial render', () => { + const errorInfo = createMockErrorInfo('rate_limit'); + const onRetry = vi.fn(); + + render(); + + expect(onRetry).not.toHaveBeenCalled(); + }); + + it('should not call onOpenSettings on initial render', () => { + const errorInfo = createMockErrorInfo('auth'); + const onOpenSettings = vi.fn(); + + render(); + + expect(onOpenSettings).not.toHaveBeenCalled(); + }); + }); + + describe('accessibility', () => { + it('should have role="alert" for screen reader announcements', () => { + const errorInfo = createMockErrorInfo('rate_limit'); + render(); + + // The error card should have role="alert" for accessibility + expect(screen.getByRole('alert')).toBeInTheDocument(); + }); + + it('should have role="alert" in compact mode', () => { + const errorInfo = createMockErrorInfo('network'); + render(); + + expect(screen.getByRole('alert')).toBeInTheDocument(); + }); + + it('should have accessible button labels', () => { + const errorInfo = createMockErrorInfo('rate_limit'); + // eslint-disable-next-line @typescript-eslint/no-empty-function -- callback not needed for this test + render( { /* no-op */ }} />); + + const button = screen.getByRole('button', { name: /retry/i }); + expect(button).toHaveTextContent('Retry'); + }); + + it('should have accessible settings button label', () => { + const errorInfo = createMockErrorInfo('auth'); + // eslint-disable-next-line @typescript-eslint/no-empty-function -- callback not needed for this test + render( { /* no-op */ }} />); + + const button = screen.getByRole('button', { name: /settings/i }); + expect(button).toHaveTextContent('Settings'); + }); + }); +}); diff --git a/apps/frontend/src/renderer/components/github-issues/components/index.ts b/apps/frontend/src/renderer/components/github-issues/components/index.ts index 0d4a559b..a5be0e9f 100644 --- a/apps/frontend/src/renderer/components/github-issues/components/index.ts +++ b/apps/frontend/src/renderer/components/github-issues/components/index.ts @@ -6,3 +6,4 @@ export { IssueListHeader } from './IssueListHeader'; export { IssueList } from './IssueList'; export { AutoFixButton } from './AutoFixButton'; export { BatchReviewWizard } from './BatchReviewWizard'; +export { GitHubErrorDisplay } from './GitHubErrorDisplay'; diff --git a/apps/frontend/src/renderer/components/github-issues/types/index.ts b/apps/frontend/src/renderer/components/github-issues/types/index.ts index 8a86ae5d..89a56a0d 100644 --- a/apps/frontend/src/renderer/components/github-issues/types/index.ts +++ b/apps/frontend/src/renderer/components/github-issues/types/index.ts @@ -3,6 +3,47 @@ import type { AutoFixConfig, AutoFixQueueItem } from '../../../../preload/api/mo export type FilterState = 'open' | 'closed' | 'all'; +/** + * Classification types for GitHub API errors. + * Used to determine appropriate icon, message, and actions for error display. + */ +export type GitHubErrorType = + | 'rate_limit' + | 'auth' + | 'permission' + | 'network' + | 'not_found' + | 'unknown'; + +/** + * Parsed GitHub error information with metadata. + * Returned by the github-error-parser utility. + * + * IMPORTANT: The `message` field contains hardcoded English strings intended + * ONLY as a fallback defaultValue for i18n translation. Direct consumers should + * use the `type` field to look up the appropriate translation key (e.g., + * 'githubErrors.rateLimitMessage') via react-i18next rather than displaying + * `message` directly. This ensures proper localization for all users. + */ +export interface GitHubErrorInfo { + /** The classified error type */ + type: GitHubErrorType; + /** + * User-friendly error message in English. + * NOTE: Use only as defaultValue for i18n - do not display directly. + * Use type field to look up translation key (e.g., 'githubErrors.rateLimitMessage'). + */ + message: string; + /** Original raw error string (for debugging/details) */ + rawMessage?: string; + /** Rate limit reset time (only for rate_limit type) */ + rateLimitResetTime?: Date; + /** Required OAuth scopes that are missing (only for permission type) */ + requiredScopes?: string[]; + /** HTTP status code if available */ + statusCode?: number; +} + export interface GitHubIssuesProps { onOpenSettings?: () => void; /** Navigate to view a task in the kanban board */ @@ -76,6 +117,10 @@ export interface IssueListProps { onSelectIssue: (issueNumber: number) => void; onInvestigate: (issue: GitHubIssue) => void; onLoadMore?: () => void; + /** Callback for retry button in error display */ + onRetry?: () => void; + /** Callback for settings button in error display */ + onOpenSettings?: () => void; } export interface EmptyStateProps { diff --git a/apps/frontend/src/renderer/components/github-issues/utils/__tests__/github-error-parser.test.ts b/apps/frontend/src/renderer/components/github-issues/utils/__tests__/github-error-parser.test.ts new file mode 100644 index 00000000..b9c8480f --- /dev/null +++ b/apps/frontend/src/renderer/components/github-issues/utils/__tests__/github-error-parser.test.ts @@ -0,0 +1,691 @@ +/** + * Unit tests for GitHub API error parser utility. + * Tests error classification, metadata extraction, and helper functions. + */ +import { describe, it, expect } from 'vitest'; +import { + parseGitHubError, + isRateLimitError, + isAuthError, + isNetworkError, + isRecoverableError, + requiresSettingsAction, +} from '../github-error-parser'; +import type { GitHubErrorType } from '../../types'; + +describe('parseGitHubError', () => { + describe('null/undefined/empty handling', () => { + it('should return unknown for null input', () => { + const result = parseGitHubError(null); + expect(result.type).toBe('unknown'); + expect(result.message).toBeDefined(); + }); + + it('should return unknown for undefined input', () => { + const result = parseGitHubError(undefined); + expect(result.type).toBe('unknown'); + expect(result.message).toBeDefined(); + }); + + it('should return unknown for empty string', () => { + const result = parseGitHubError(''); + expect(result.type).toBe('unknown'); + expect(result.message).toBeDefined(); + }); + + it('should return unknown for whitespace-only string', () => { + const result = parseGitHubError(' '); + expect(result.type).toBe('unknown'); + expect(result.message).toBeDefined(); + }); + }); + + describe('rate_limit errors', () => { + it('should detect "rate limit exceeded" pattern', () => { + const result = parseGitHubError('GitHub API error: rate limit exceeded'); + expect(result.type).toBe('rate_limit'); + expect(result.message).toContain('rate limit'); + expect(result.statusCode).toBe(403); + }); + + it('should detect "API rate limit exceeded" pattern', () => { + const result = parseGitHubError('API rate limit exceeded for user'); + expect(result.type).toBe('rate_limit'); + }); + + it('should detect "too many requests" pattern', () => { + const result = parseGitHubError('Error: too many requests'); + expect(result.type).toBe('rate_limit'); + }); + + it('should detect "403 rate limit" pattern', () => { + const result = parseGitHubError('403 rate limit reached'); + expect(result.type).toBe('rate_limit'); + expect(result.statusCode).toBe(403); + }); + + it('should detect "abuse rate limit" pattern', () => { + const result = parseGitHubError('Abuse rate limit triggered'); + expect(result.type).toBe('rate_limit'); + }); + + it('should detect "secondary rate limit" pattern', () => { + const result = parseGitHubError('Secondary rate limit exceeded'); + expect(result.type).toBe('rate_limit'); + }); + + it('should extract rate limit reset time from ISO date format', () => { + const result = parseGitHubError('rate limit exceeded, resets at 2024-01-15T12:00:00Z'); + expect(result.type).toBe('rate_limit'); + expect(result.rateLimitResetTime).toBeInstanceOf(Date); + expect(result.rateLimitResetTime?.getUTCFullYear()).toBe(2024); + }); + + it('should extract rate limit reset time from Unix timestamp', () => { + const result = parseGitHubError('X-RateLimit-Reset: 1705312800'); + expect(result.type).toBe('rate_limit'); + expect(result.rateLimitResetTime).toBeInstanceOf(Date); + }); + + it('should generate user-friendly message with time remaining', () => { + // Create a date 5 minutes in the future + const futureDate = new Date(Date.now() + 5 * 60 * 1000); + const isoString = futureDate.toISOString(); + const result = parseGitHubError(`rate limit exceeded, resets at ${isoString}`); + expect(result.type).toBe('rate_limit'); + expect(result.message).toContain('rate limit'); + }); + + it('should generate fallback message when reset time has passed', () => { + // Create a date in the past + const pastDate = new Date(Date.now() - 5 * 60 * 1000); + const isoString = pastDate.toISOString(); + const result = parseGitHubError(`rate limit exceeded, resets at ${isoString}`); + expect(result.type).toBe('rate_limit'); + expect(result.message).toContain('moment'); + }); + + it('should include raw message truncated to MAX_RAW_ERROR_LENGTH', () => { + const longError = 'rate limit exceeded ' + 'x'.repeat(600); + const result = parseGitHubError(longError); + expect(result.type).toBe('rate_limit'); + expect(result.rawMessage).toBeDefined(); + expect(result.rawMessage?.length).toBeLessThanOrEqual(503); // 500 + '...' + }); + }); + + describe('auth errors', () => { + it('should detect "401" pattern', () => { + const result = parseGitHubError('HTTP 401 Unauthorized'); + expect(result.type).toBe('auth'); + expect(result.statusCode).toBe(401); + }); + + it('should detect "unauthorized" pattern', () => { + const result = parseGitHubError('Error: unauthorized access'); + expect(result.type).toBe('auth'); + }); + + it('should detect "bad credentials" pattern', () => { + const result = parseGitHubError('Bad credentials'); + expect(result.type).toBe('auth'); + }); + + it('should detect "authentication failed" pattern', () => { + const result = parseGitHubError('Authentication failed'); + expect(result.type).toBe('auth'); + }); + + it('should detect "invalid token" pattern', () => { + const result = parseGitHubError('Invalid token provided'); + expect(result.type).toBe('auth'); + }); + + it('should detect "token expired" pattern', () => { + const result = parseGitHubError('Token expired'); + expect(result.type).toBe('auth'); + }); + + it('should detect "not authenticated" pattern', () => { + const result = parseGitHubError('Not authenticated'); + expect(result.type).toBe('auth'); + }); + + it('should generate user-friendly message mentioning Settings', () => { + const result = parseGitHubError('401 Unauthorized'); + expect(result.message).toContain('authentication'); + expect(result.message).toContain('Settings'); + }); + }); + + describe('not_found errors', () => { + it('should detect "404" pattern', () => { + const result = parseGitHubError('HTTP 404 Not Found'); + expect(result.type).toBe('not_found'); + expect(result.statusCode).toBe(404); + }); + + it('should detect "not found" pattern', () => { + const result = parseGitHubError('Repository not found'); + expect(result.type).toBe('not_found'); + }); + + it('should detect "no such repository" pattern', () => { + const result = parseGitHubError('No such repository exists'); + expect(result.type).toBe('not_found'); + }); + + it('should detect "does not exist" pattern', () => { + const result = parseGitHubError('Resource does not exist'); + expect(result.type).toBe('not_found'); + }); + + it('should detect "user not found" pattern', () => { + const result = parseGitHubError('User not found'); + expect(result.type).toBe('not_found'); + }); + + it('should generate user-friendly message about verifying repository', () => { + const result = parseGitHubError('404 Not Found'); + expect(result.message).toContain('not found'); + expect(result.message).toContain('verify'); + }); + }); + + describe('network errors', () => { + it('should detect "network error" pattern', () => { + const result = parseGitHubError('Network error'); + expect(result.type).toBe('network'); + }); + + it('should detect "failed to fetch" pattern', () => { + const result = parseGitHubError('Failed to fetch data'); + expect(result.type).toBe('network'); + }); + + it('should detect "ECONNREFUSED" pattern', () => { + const result = parseGitHubError('Error: ECONNREFUSED'); + expect(result.type).toBe('network'); + }); + + it('should detect "ECONNRESET" pattern', () => { + const result = parseGitHubError('Error: ECONNRESET'); + expect(result.type).toBe('network'); + }); + + it('should detect "ETIMEDOUT" pattern', () => { + const result = parseGitHubError('Error: ETIMEDOUT'); + expect(result.type).toBe('network'); + }); + + it('should detect "connection refused" pattern', () => { + const result = parseGitHubError('Connection refused'); + expect(result.type).toBe('network'); + }); + + it('should detect "connection timeout" pattern', () => { + const result = parseGitHubError('Connection timeout'); + expect(result.type).toBe('network'); + }); + + it('should detect "DNS error" pattern', () => { + const result = parseGitHubError('DNS error occurred'); + expect(result.type).toBe('network'); + }); + + it('should detect "offline" pattern', () => { + const result = parseGitHubError('You are offline'); + expect(result.type).toBe('network'); + }); + + it('should detect "no internet" pattern', () => { + const result = parseGitHubError('No internet connection'); + expect(result.type).toBe('network'); + }); + + it('should generate user-friendly message about internet connection', () => { + const result = parseGitHubError('Network error'); + expect(result.message).toContain('internet'); + }); + }); + + describe('permission errors', () => { + it('should detect "403" pattern (without rate limit context)', () => { + const result = parseGitHubError('HTTP 403 Forbidden'); + expect(result.type).toBe('permission'); + expect(result.statusCode).toBe(403); + }); + + it('should detect "forbidden" pattern', () => { + const result = parseGitHubError('Access forbidden'); + expect(result.type).toBe('permission'); + }); + + it('should detect "permission denied" pattern', () => { + const result = parseGitHubError('Permission denied'); + expect(result.type).toBe('permission'); + }); + + it('should detect "insufficient scope" pattern', () => { + const result = parseGitHubError('Insufficient scope'); + expect(result.type).toBe('permission'); + }); + + it('should detect "access denied" pattern', () => { + const result = parseGitHubError('Access denied'); + expect(result.type).toBe('permission'); + }); + + it('should detect "repository access denied" pattern', () => { + const result = parseGitHubError('Repository access denied'); + expect(result.type).toBe('permission'); + }); + + it('should detect "requires admin access" pattern', () => { + const result = parseGitHubError('Requires admin access'); + expect(result.type).toBe('permission'); + }); + + it('should detect "missing required scope" pattern', () => { + const result = parseGitHubError('Missing required scope'); + expect(result.type).toBe('permission'); + }); + + it('should extract required scopes from error message with 403', () => { + const result = parseGitHubError('403 Forbidden - missing scopes: repo, read:org'); + expect(result.type).toBe('permission'); + expect(result.requiredScopes).toContain('repo'); + expect(result.requiredScopes).toContain('read:org'); + }); + + it('should extract scopes from "requires:" format with 403', () => { + const result = parseGitHubError('403 - Requires: repo, workflow'); + expect(result.type).toBe('permission'); + expect(result.requiredScopes).toContain('repo'); + expect(result.requiredScopes).toContain('workflow'); + }); + + it('should extract scopes from X-Accepted-OAuth-Scopes header with 403', () => { + const result = parseGitHubError('403 Forbidden X-Accepted-OAuth-Scopes: repo'); + expect(result.type).toBe('permission'); + expect(result.requiredScopes).toContain('repo'); + }); + + it('should generate user-friendly message with scopes', () => { + const result = parseGitHubError('403 Forbidden - missing scopes: repo, workflow'); + expect(result.message).toContain('repo'); + expect(result.message).toContain('workflow'); + expect(result.message).toContain('Settings'); + }); + + it('should generate user-friendly message without scopes', () => { + const result = parseGitHubError('403 Forbidden'); + expect(result.message).toContain('permission'); + expect(result.message).toContain('Settings'); + }); + }); + + describe('unknown errors', () => { + it('should return unknown for unrecognized error patterns', () => { + const result = parseGitHubError('Something unexpected happened'); + expect(result.type).toBe('unknown'); + expect(result.message).toBeDefined(); + }); + + it('should include raw message for unknown errors', () => { + const result = parseGitHubError('Custom error message'); + expect(result.rawMessage).toBe('Custom error message'); + }); + + it('should extract status code even for unknown errors', () => { + const result = parseGitHubError('HTTP 500 Internal Server Error'); + expect(result.type).toBe('unknown'); + expect(result.statusCode).toBe(500); + }); + }); + + describe('error classification priority', () => { + it('should prioritize rate_limit over permission (both 403)', () => { + const result = parseGitHubError('403 rate limit exceeded'); + expect(result.type).toBe('rate_limit'); + }); + + it('should classify as permission when 403 without rate limit context', () => { + const result = parseGitHubError('403 forbidden'); + expect(result.type).toBe('permission'); + }); + + it('should handle errors with multiple patterns correctly', () => { + // Rate limit should take priority + const result = parseGitHubError('403 API rate limit exceeded'); + expect(result.type).toBe('rate_limit'); + }); + + it('should prioritize auth over not_found when both patterns present', () => { + // "401" should be classified as auth, not not_found + const result = parseGitHubError('HTTP 401 Unauthorized - user not found'); + expect(result.type).toBe('auth'); + }); + + it('should prioritize auth over network when 401 appears with network context', () => { + const result = parseGitHubError('Network error: HTTP 401'); + expect(result.type).toBe('auth'); + }); + + it('should classify as not_found when 404 without auth patterns', () => { + const result = parseGitHubError('HTTP 404 Not Found'); + expect(result.type).toBe('not_found'); + }); + + it('should not match bare 401 in unrelated numbers', () => { + // The word boundary should prevent matching "1401" as a 401 error + const result = parseGitHubError('Error code 14010 occurred'); + expect(result.type).toBe('unknown'); + }); + + it('should not match bare 404 embedded in other numbers', () => { + // The word boundary should prevent matching "404" embedded in "14040" + const result = parseGitHubError('Error code 14040 occurred'); + expect(result.type).toBe('unknown'); + }); + }); + + describe('edge cases', () => { + it('should handle multiline error messages', () => { + const result = parseGitHubError(`Error occurred: + HTTP 401 Unauthorized + Please check your credentials`); + expect(result.type).toBe('auth'); + }); + + it('should handle case-insensitive matching', () => { + const testCases = [ + { input: 'RATE LIMIT EXCEEDED', expected: 'rate_limit' as GitHubErrorType }, + { input: 'UNAUTHORIZED', expected: 'auth' as GitHubErrorType }, + { input: 'NOT FOUND', expected: 'not_found' as GitHubErrorType }, + { input: 'NETWORK ERROR', expected: 'network' as GitHubErrorType }, + { input: 'FORBIDDEN', expected: 'permission' as GitHubErrorType }, + ]; + + for (const { input, expected } of testCases) { + const result = parseGitHubError(input); + expect(result.type).toBe(expected); + } + }); + + it('should handle errors with JSON content', () => { + const result = parseGitHubError('{"message":"Bad credentials","status":401}'); + expect(result.type).toBe('auth'); + }); + + it('should handle errors with leading/trailing whitespace', () => { + const result = parseGitHubError(' 401 Unauthorized '); + expect(result.type).toBe('auth'); + }); + + it('should sanitize very long error messages', () => { + const longError = 'A'.repeat(1000); + const result = parseGitHubError(longError); + expect(result.rawMessage?.length).toBeLessThanOrEqual(503); + expect(result.rawMessage).toContain('...'); + }); + + it('should not include rateLimitResetTime for non-rate-limit errors', () => { + const result = parseGitHubError('401 Unauthorized'); + expect(result.rateLimitResetTime).toBeUndefined(); + }); + + it('should not include requiredScopes for non-permission errors', () => { + const result = parseGitHubError('401 Unauthorized'); + expect(result.requiredScopes).toBeUndefined(); + }); + }); +}); + +describe('isRateLimitError', () => { + it('should return true for rate limit errors', () => { + expect(isRateLimitError('rate limit exceeded')).toBe(true); + expect(isRateLimitError('API rate limit exceeded')).toBe(true); + expect(isRateLimitError('too many requests')).toBe(true); + }); + + it('should return false for non-rate-limit errors', () => { + expect(isRateLimitError('401 Unauthorized')).toBe(false); + expect(isRateLimitError('404 Not Found')).toBe(false); + expect(isRateLimitError('Network error')).toBe(false); + }); + + it('should return false for null/undefined/empty', () => { + expect(isRateLimitError(null)).toBe(false); + expect(isRateLimitError(undefined)).toBe(false); + expect(isRateLimitError('')).toBe(false); + }); + + it('should use parsedInfo when provided', () => { + const parsedInfo = { type: 'rate_limit' as const, message: 'test' }; + expect(isRateLimitError('unrelated error', parsedInfo)).toBe(true); + expect(isRateLimitError(null, parsedInfo)).toBe(true); + expect(isRateLimitError(undefined, parsedInfo)).toBe(true); + }); + + it('should ignore parsedInfo when error type differs', () => { + const authParsedInfo = { type: 'auth' as const, message: 'test' }; + expect(isRateLimitError('rate limit exceeded', authParsedInfo)).toBe(false); + }); +}); + +describe('isAuthError', () => { + it('should return true for auth errors', () => { + expect(isAuthError('401 Unauthorized')).toBe(true); + expect(isAuthError('Bad credentials')).toBe(true); + expect(isAuthError('Invalid token')).toBe(true); + expect(isAuthError('Not authenticated')).toBe(true); + }); + + it('should return false for non-auth errors', () => { + expect(isAuthError('rate limit exceeded')).toBe(false); + expect(isAuthError('404 Not Found')).toBe(false); + expect(isAuthError('Network error')).toBe(false); + }); + + it('should return false for null/undefined/empty', () => { + expect(isAuthError(null)).toBe(false); + expect(isAuthError(undefined)).toBe(false); + expect(isAuthError('')).toBe(false); + }); + + it('should use parsedInfo when provided', () => { + const parsedInfo = { type: 'auth' as const, message: 'test' }; + expect(isAuthError('unrelated error', parsedInfo)).toBe(true); + expect(isAuthError(null, parsedInfo)).toBe(true); + expect(isAuthError(undefined, parsedInfo)).toBe(true); + }); + + it('should ignore parsedInfo when error type differs', () => { + const rateLimitParsedInfo = { type: 'rate_limit' as const, message: 'test' }; + expect(isAuthError('401 Unauthorized', rateLimitParsedInfo)).toBe(false); + }); +}); + +describe('isNetworkError', () => { + it('should return true for network errors', () => { + expect(isNetworkError('Network error')).toBe(true); + expect(isNetworkError('Failed to fetch')).toBe(true); + expect(isNetworkError('ECONNREFUSED')).toBe(true); + expect(isNetworkError('Connection timeout')).toBe(true); + }); + + it('should return false for non-network errors', () => { + expect(isNetworkError('401 Unauthorized')).toBe(false); + expect(isNetworkError('rate limit exceeded')).toBe(false); + expect(isNetworkError('404 Not Found')).toBe(false); + }); + + it('should return false for null/undefined/empty', () => { + expect(isNetworkError(null)).toBe(false); + expect(isNetworkError(undefined)).toBe(false); + expect(isNetworkError('')).toBe(false); + }); + + it('should use parsedInfo when provided', () => { + const parsedInfo = { type: 'network' as const, message: 'test' }; + expect(isNetworkError('unrelated error', parsedInfo)).toBe(true); + expect(isNetworkError(null, parsedInfo)).toBe(true); + expect(isNetworkError(undefined, parsedInfo)).toBe(true); + }); + + it('should ignore parsedInfo when error type differs', () => { + const authParsedInfo = { type: 'auth' as const, message: 'test' }; + expect(isNetworkError('Network error', authParsedInfo)).toBe(false); + }); +}); + +describe('isRecoverableError', () => { + it('should return true for recoverable errors (rate_limit, network, unknown)', () => { + expect(isRecoverableError('rate limit exceeded')).toBe(true); + expect(isRecoverableError('Network error')).toBe(true); + expect(isRecoverableError('Unknown error occurred')).toBe(true); + }); + + it('should return false for non-recoverable errors (auth, permission, not_found)', () => { + expect(isRecoverableError('401 Unauthorized')).toBe(false); + expect(isRecoverableError('403 Forbidden')).toBe(false); + expect(isRecoverableError('404 Not Found')).toBe(false); + }); + + it('should return false for null/undefined/empty', () => { + expect(isRecoverableError(null)).toBe(false); + expect(isRecoverableError(undefined)).toBe(false); + expect(isRecoverableError('')).toBe(false); + }); + + it('should use parsedInfo when provided', () => { + const rateLimitInfo = { type: 'rate_limit' as const, message: 'test' }; + const networkInfo = { type: 'network' as const, message: 'test' }; + const unknownInfo = { type: 'unknown' as const, message: 'test' }; + expect(isRecoverableError('unrelated error', rateLimitInfo)).toBe(true); + expect(isRecoverableError(null, networkInfo)).toBe(true); + expect(isRecoverableError(undefined, unknownInfo)).toBe(true); + }); + + it('should ignore parsedInfo when error type is non-recoverable', () => { + const authParsedInfo = { type: 'auth' as const, message: 'test' }; + const permissionParsedInfo = { type: 'permission' as const, message: 'test' }; + const notFoundParsedInfo = { type: 'not_found' as const, message: 'test' }; + expect(isRecoverableError('Network error', authParsedInfo)).toBe(false); + expect(isRecoverableError('rate limit exceeded', permissionParsedInfo)).toBe(false); + expect(isRecoverableError('unknown', notFoundParsedInfo)).toBe(false); + }); +}); + +describe('requiresSettingsAction', () => { + it('should return true for errors requiring settings action (auth, permission)', () => { + expect(requiresSettingsAction('401 Unauthorized')).toBe(true); + expect(requiresSettingsAction('403 Forbidden')).toBe(true); + expect(requiresSettingsAction('Invalid token')).toBe(true); + expect(requiresSettingsAction('403 Forbidden - missing scopes: repo')).toBe(true); + }); + + it('should return false for errors not requiring settings (rate_limit, network, not_found, unknown)', () => { + expect(requiresSettingsAction('rate limit exceeded')).toBe(false); + expect(requiresSettingsAction('Network error')).toBe(false); + expect(requiresSettingsAction('404 Not Found')).toBe(false); + expect(requiresSettingsAction('Unknown error')).toBe(false); + }); + + it('should return false for null/undefined/empty', () => { + expect(requiresSettingsAction(null)).toBe(false); + expect(requiresSettingsAction(undefined)).toBe(false); + expect(requiresSettingsAction('')).toBe(false); + }); + + it('should use parsedInfo when provided', () => { + const authInfo = { type: 'auth' as const, message: 'test' }; + const permissionInfo = { type: 'permission' as const, message: 'test' }; + expect(requiresSettingsAction('unrelated error', authInfo)).toBe(true); + expect(requiresSettingsAction(null, permissionInfo)).toBe(true); + expect(requiresSettingsAction(undefined, authInfo)).toBe(true); + }); + + it('should ignore parsedInfo when error type does not require settings', () => { + const rateLimitInfo = { type: 'rate_limit' as const, message: 'test' }; + const networkInfo = { type: 'network' as const, message: 'test' }; + const notFoundInfo = { type: 'not_found' as const, message: 'test' }; + expect(requiresSettingsAction('401 Unauthorized', rateLimitInfo)).toBe(false); + expect(requiresSettingsAction('403 Forbidden', networkInfo)).toBe(false); + expect(requiresSettingsAction('invalid token', notFoundInfo)).toBe(false); + }); +}); + +describe('cross-cutting concerns', () => { + describe('consistency between parseGitHubError and helper functions', () => { + it('should have consistent rate_limit detection', () => { + const error = 'rate limit exceeded'; + const parsed = parseGitHubError(error); + expect(parsed.type).toBe('rate_limit'); + expect(isRateLimitError(error)).toBe(true); + }); + + it('should have consistent auth detection', () => { + const error = '401 Unauthorized'; + const parsed = parseGitHubError(error); + expect(parsed.type).toBe('auth'); + expect(isAuthError(error)).toBe(true); + }); + + it('should have consistent network detection', () => { + const error = 'Network error'; + const parsed = parseGitHubError(error); + expect(parsed.type).toBe('network'); + expect(isNetworkError(error)).toBe(true); + }); + + it('should have consistent recoverable classification', () => { + const errors = ['rate limit exceeded', 'Network error', 'Unknown error']; + for (const error of errors) { + const parsed = parseGitHubError(error); + expect(isRecoverableError(error)).toBe(['rate_limit', 'network', 'unknown'].includes(parsed.type)); + } + }); + + it('should have consistent settings action classification', () => { + const errors = ['401 Unauthorized', '403 Forbidden']; + for (const error of errors) { + const parsed = parseGitHubError(error); + expect(requiresSettingsAction(error)).toBe(['auth', 'permission'].includes(parsed.type)); + } + }); + }); + + describe('statusCode extraction', () => { + it('should extract 403 for rate_limit errors', () => { + const result = parseGitHubError('rate limit exceeded'); + expect(result.statusCode).toBe(403); + }); + + it('should extract 401 for auth errors', () => { + const result = parseGitHubError('Bad credentials'); + expect(result.statusCode).toBe(401); + }); + + it('should extract 404 for not_found errors', () => { + const result = parseGitHubError('Not found'); + expect(result.statusCode).toBe(404); + }); + + it('should extract 403 for permission errors', () => { + const result = parseGitHubError('Forbidden'); + expect(result.statusCode).toBe(403); + }); + + it('should extract status code from message when present', () => { + const result = parseGitHubError('HTTP 429 Too Many Requests'); + expect(result.statusCode).toBe(429); + }); + + it('should not extract invalid status codes', () => { + const result = parseGitHubError('Error 999'); + expect(result.statusCode).toBeUndefined(); + }); + }); +}); diff --git a/apps/frontend/src/renderer/components/github-issues/utils/github-error-parser.ts b/apps/frontend/src/renderer/components/github-issues/utils/github-error-parser.ts new file mode 100644 index 00000000..f0521773 --- /dev/null +++ b/apps/frontend/src/renderer/components/github-issues/utils/github-error-parser.ts @@ -0,0 +1,497 @@ +/** + * GitHub API error parser utility. + * Parses raw error strings to classify GitHub API errors and extract metadata. + */ + +import type { GitHubErrorType, GitHubErrorInfo } from '../types'; + +/** + * Maximum length for raw error messages stored in GitHubErrorInfo. + * Truncates to prevent memory bloat and UI issues. + */ +const MAX_RAW_ERROR_LENGTH = 500; + +/** + * Patterns for rate limit errors (HTTP 403 with rate limit context). + * Note: Pattern 1 covers all "rate limit" variations (api rate limit exceeded, + * abuse rate limit, secondary rate limit, etc.) via substring matching. + */ +const RATE_LIMIT_PATTERNS = [ + /rate\s*limit/i, // Covers all variations containing "rate limit" + /too\s*many\s*requests/i, + /403.*rate/i, +]; + +/** + * Patterns for authentication errors (HTTP 401) + * Note: Bare status codes are intentionally omitted here - STATUS_CODE_PATTERN + * handles HTTP-context-aware matching to avoid false positives. + */ +const AUTH_PATTERNS = [ + /unauthorized/i, + /bad\s*credentials/i, + /authentication\s*failed/i, + /invalid\s*(oauth\s*)?token/i, + /token\s*(is\s*)?(invalid|expired|required)/i, + /not\s*authenticated/i, + /requires\s*authentication/i, // GitHub 401 response body +]; + +/** + * Patterns for permission/scope errors (HTTP 403 with scope context) + * Note: Bare status codes are intentionally omitted here - STATUS_CODE_PATTERN + * handles HTTP-context-aware matching to avoid false positives. + */ +const PERMISSION_PATTERNS = [ + /forbidden/i, + /permission\s*denied/i, + /insufficient\s*(scope|permission)/i, + /access\s*denied/i, + /repository\s*access\s*denied/i, + /not\s*authorized\s*to\s*access/i, + /requires\s*(admin|write|read)\s*access/i, + /missing\s*required\s*scope/i, + // Matches "requires: repo" or "requires workflow" for OAuth scope context + // Uses specific scope names to avoid matching "requires authentication" (auth error) + /requires[:\s]+(?:repo|admin|write|read|workflow|org|gist|notification|user|project|package|delete|discussion)/i, +]; + +/** + * Patterns for not found errors (HTTP 404) + * Note: Bare status codes are intentionally omitted here - STATUS_CODE_PATTERN + * handles HTTP-context-aware matching to avoid false positives (e.g., "Issue #404"). + */ +const NOT_FOUND_PATTERNS = [ + /not\s*found/i, + /no\s*such\s*(repository|repo|issue|resource)/i, + /does\s*not\s*exist/i, + /repository\s*not\s*found/i, + /user\s*not\s*found/i, +]; + +/** + * Patterns for network/connectivity errors + */ +const NETWORK_PATTERNS = [ + /network\s*(error|failed|unreachable)/i, + /failed\s*to\s*fetch/i, + /enetunreach/i, + /econnrefused/i, + /econnreset/i, + /etimedout/i, + /dns\s*(error|failed)/i, + /offline/i, + /no\s*internet/i, + /unable\s*to\s*connect/i, + /connection\s*(refused|reset|timeout|failed)/i, +]; + +/** + * Pattern to extract required OAuth scopes from error messages + * Matches formats like: + * - "requires: repo, read:org" + * - "missing scopes: repo, workflow" + * - "X-Accepted-OAuth-Scopes: repo" + * Stops at sentence boundaries or non-scope characters + */ +const REQUIRED_SCOPES_PATTERN = /(?:requires?[:\s]*|missing\s*scopes?[:\s]*|X-Accepted-OAuth-Scopes[:\s]*)([a-z0-9_:]+(?:[,\s]+[a-z0-9_:]+)*)/i; + +/** + * Pattern to extract HTTP status code from error messages. + * Matches status codes preceded by HTTP context keywords or at string start + * (for common error formats like "403 Forbidden"). + */ +const STATUS_CODE_PATTERN = /(?:^|HTTP\s*|status[:\s]*|error[:\s]*|code[:\s]*)\b([1-5]\d{2})\b/i; + +/** + * Sanitize error output to a reasonable length. + * Prevents memory bloat and UI issues from very long error messages. + */ +function sanitizeRawError(error: string): string { + if (error.length > MAX_RAW_ERROR_LENGTH) { + return error.substring(0, MAX_RAW_ERROR_LENGTH) + '...'; + } + return error; +} + +/** + * Maximum reasonable reset duration in seconds (24 hours). + * Prevents malformed error strings from creating far-future dates. + */ +const MAX_RESET_SECONDS = 86400; + +/** + * Extract rate limit reset time from error message. + * Parses various formats and returns a Date object if found. + * Handles both absolute timestamps and relative durations ("in X seconds"). + */ +function extractRateLimitResetTime(error: string): Date | undefined { + // First, try to match relative duration pattern (e.g., "reset in 3600 seconds") + const relativePattern = /reset[s]?\s*in[:\s]*(\d+)\s*seconds?/i; + const relativeMatch = error.match(relativePattern); + if (relativeMatch) { + const seconds = parseInt(relativeMatch[1], 10); + // Validate: positive, non-NaN, and within reasonable bounds (24 hours max) + if (!Number.isNaN(seconds) && seconds > 0 && seconds <= MAX_RESET_SECONDS) { + return new Date(Date.now() + seconds * 1000); + } + } + + // Then try absolute timestamp pattern + const absolutePattern = /(?:reset[s]?\s*at[:\s]*|X-RateLimit-Reset[:\s]*)(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z?|\d+)/i; + const match = error.match(absolutePattern); + if (!match) { + return undefined; + } + + const resetValue = match[1].trim(); + + // Check if it's an ISO date string + if (resetValue.includes('-') && resetValue.includes('T')) { + const date = new Date(resetValue); + if (Number.isNaN(date.getTime())) return undefined; + // Validate: within reasonable bounds (24 hours max from now) + if (date.getTime() - Date.now() > MAX_RESET_SECONDS * 1000) return undefined; + return date; + } + + // Check if it's a Unix timestamp (seconds or milliseconds) + const numericValue = parseInt(resetValue, 10); + if (!Number.isNaN(numericValue)) { + // GitHub API uses seconds, JavaScript uses milliseconds + // Values > 1e12 are likely milliseconds already + const timestamp = numericValue > 1e12 ? numericValue : numericValue * 1000; + const date = new Date(timestamp); + if (Number.isNaN(date.getTime())) return undefined; + // Validate: within reasonable bounds (24 hours max from now) + if (date.getTime() - Date.now() > MAX_RESET_SECONDS * 1000) return undefined; + return date; + } + + return undefined; +} + +/** + * Extract required OAuth scopes from error message. + * Returns an array of scope strings if found. + */ +function extractRequiredScopes(error: string): string[] | undefined { + const match = error.match(REQUIRED_SCOPES_PATTERN); + if (!match) { + return undefined; + } + + const scopes = match[1] + .split(/[,\s]+/) + .map(s => s.trim()) + .filter(s => s.length > 0); + + return scopes.length > 0 ? scopes : undefined; +} + +/** + * Extract HTTP status code from error message. + */ +function extractStatusCode(error: string): number | undefined { + const match = error.match(STATUS_CODE_PATTERN); + if (!match) { + return undefined; + } + + const code = parseInt(match[1], 10); + // Only return valid HTTP status codes + if (code >= 100 && code < 600) { + return code; + } + return undefined; +} + +/** + * Check if the error matches any of the given patterns. + */ +function matchesPatterns(error: string, patterns: RegExp[]): boolean { + return patterns.some(pattern => pattern.test(error)); +} + +/** + * Get a user-friendly message for rate limit errors. + */ +function getRateLimitMessage(_error: string, resetTime?: Date): string { + if (resetTime) { + const now = new Date(); + const diffMs = resetTime.getTime() - now.getTime(); + + if (diffMs > 0) { + const diffMins = Math.ceil(diffMs / 60000); + if (diffMins < 60) { + return `GitHub API rate limit reached. Please wait ${diffMins} minute${diffMins !== 1 ? 's' : ''} before trying again.`; + } + const diffHours = Math.ceil(diffMins / 60); + return `GitHub API rate limit reached. Rate limit resets in approximately ${diffHours} hour${diffHours !== 1 ? 's' : ''}.`; + } + } + + return 'GitHub API rate limit reached. Please wait a moment before trying again.'; +} + +/** + * Get a user-friendly message for authentication errors. + */ +function getAuthMessage(): string { + return 'GitHub authentication failed. Please check your GitHub token in Settings and try again.'; +} + +/** + * Get a user-friendly message for permission errors. + */ +function getPermissionMessage(scopes?: string[]): string { + if (scopes && scopes.length > 0) { + return `GitHub permission denied. Your token is missing required scopes: ${scopes.join(', ')}. Please update your GitHub token in Settings.`; + } + return 'GitHub permission denied. Your token may not have the required access. Please check your token permissions in Settings.'; +} + +/** + * Get a user-friendly message for not found errors. + */ +function getNotFoundMessage(): string { + return 'The requested GitHub resource was not found. Please verify the repository exists and you have access to it.'; +} + +/** + * Get a user-friendly message for network errors. + */ +function getNetworkMessage(): string { + return 'Unable to connect to GitHub. Please check your internet connection and try again.'; +} + +/** + * Get a user-friendly message for unknown errors. + */ +function getUnknownMessage(): string { + return 'An unexpected error occurred while communicating with GitHub. Please try again.'; +} + +/** + * Classify error type based on pattern matching and optional status code. + * Priority: rate_limit > auth > permission > not_found > network > unknown + * Note: Permission checks run before not_found to properly classify 403 responses. + * Status code fallback takes priority over network patterns since HTTP status + * codes are more specific than generic network error text. + * @param error - The error string to classify + * @param statusCode - Optional HTTP status code extracted with context (helps classify when text patterns don't match) + */ +function classifyError(error: string, statusCode?: number): GitHubErrorType { + // Check rate limit first (403 can also be permission, but rate limit is more specific) + if (matchesPatterns(error, RATE_LIMIT_PATTERNS)) { + return 'rate_limit'; + } + + // Check auth (401 is always auth) + if (matchesPatterns(error, AUTH_PATTERNS)) { + return 'auth'; + } + + // Check permission (403 without rate limit context) before not_found + // to properly classify 403 responses that might contain "not found" text + if (matchesPatterns(error, PERMISSION_PATTERNS)) { + return 'permission'; + } + + // Check not found (404 is always not_found) + if (matchesPatterns(error, NOT_FOUND_PATTERNS)) { + return 'not_found'; + } + + // Use status code fallback BEFORE network patterns + // HTTP status codes are more specific than generic network error text + if (statusCode === 401) return 'auth'; + if (statusCode === 403) return 'permission'; + if (statusCode === 404) return 'not_found'; + + // Check network errors (only if no status code fallback matched) + if (matchesPatterns(error, NETWORK_PATTERNS)) { + return 'network'; + } + + return 'unknown'; +} + +/** + * Parse a GitHub API error string and return classified error information. + * + * IMPORTANT: The returned `message` field contains hardcoded English strings + * intended ONLY as a fallback defaultValue for i18n translation. Consumers + * should use the `type` field to look up the appropriate translation key + * (e.g., 'githubErrors.rateLimitMessage') via react-i18next rather than + * displaying `message` directly. This ensures proper localization. + * + * Translation key mapping by type: + * - rate_limit → 'githubErrors.rateLimitMessage' (or rateLimitMessageMinutes/Hours) + * - auth → 'githubErrors.authMessage' + * - permission → 'githubErrors.permissionMessage' (or permissionMessageScopes) + * - not_found → 'githubErrors.notFoundMessage' + * - network → 'githubErrors.networkMessage' + * - unknown → 'githubErrors.unknownMessage' + * + * @param error - The raw error string (typically from issues-store error state) + * @returns GitHubErrorInfo object with classified type, user-friendly message, and metadata + * + * @example + * ```typescript + * const errorInfo = parseGitHubError('GitHub API error: 403 - API rate limit exceeded'); + * // Use type to get i18n key, message only as fallback: + * // t(`githubErrors.${errorInfo.type}Message`, { defaultValue: errorInfo.message }) + * ``` + */ +export function parseGitHubError(error: string | null | undefined): GitHubErrorInfo { + // Handle null/undefined/empty errors + if (!error || typeof error !== 'string' || error.trim() === '') { + return { + type: 'unknown', + message: getUnknownMessage(), + }; + } + + const trimmedError = error.trim(); + // Extract status code first so we can use it for classification fallback + const statusCode = extractStatusCode(trimmedError); + const errorType = classifyError(trimmedError, statusCode); + + switch (errorType) { + case 'rate_limit': { + const resetTime = extractRateLimitResetTime(trimmedError); + return { + type: 'rate_limit', + message: getRateLimitMessage(trimmedError, resetTime), + rawMessage: sanitizeRawError(trimmedError), + rateLimitResetTime: resetTime, + statusCode: statusCode ?? 403, + }; + } + + case 'auth': + return { + type: 'auth', + message: getAuthMessage(), + rawMessage: sanitizeRawError(trimmedError), + statusCode: statusCode ?? 401, + }; + + case 'permission': { + const scopes = extractRequiredScopes(trimmedError); + return { + type: 'permission', + message: getPermissionMessage(scopes), + rawMessage: sanitizeRawError(trimmedError), + requiredScopes: scopes, + statusCode: statusCode ?? 403, + }; + } + + case 'not_found': + return { + type: 'not_found', + message: getNotFoundMessage(), + rawMessage: sanitizeRawError(trimmedError), + statusCode: statusCode ?? 404, + }; + + case 'network': + return { + type: 'network', + message: getNetworkMessage(), + rawMessage: sanitizeRawError(trimmedError), + }; + + default: + return { + type: 'unknown', + message: getUnknownMessage(), + rawMessage: sanitizeRawError(trimmedError), + statusCode, + }; + } +} + +/** + * Check if an error is a rate limit error. + * Convenience function for quick checks without full parsing. + * @param error - Raw error string or null/undefined + * @param parsedInfo - Optional pre-parsed GitHubErrorInfo to avoid re-classification + */ +export function isRateLimitError( + error: string | null | undefined, + parsedInfo?: GitHubErrorInfo | null +): boolean { + if (parsedInfo) return parsedInfo.type === 'rate_limit'; + if (!error) return false; + const trimmed = error.trim(); + return classifyError(trimmed, extractStatusCode(trimmed)) === 'rate_limit'; +} + +/** + * Check if an error is an authentication error. + * Convenience function for quick checks without full parsing. + * @param error - Raw error string or null/undefined + * @param parsedInfo - Optional pre-parsed GitHubErrorInfo to avoid re-classification + */ +export function isAuthError( + error: string | null | undefined, + parsedInfo?: GitHubErrorInfo | null +): boolean { + if (parsedInfo) return parsedInfo.type === 'auth'; + if (!error) return false; + const trimmed = error.trim(); + return classifyError(trimmed, extractStatusCode(trimmed)) === 'auth'; +} + +/** + * Check if an error is a network error. + * Convenience function for quick checks without full parsing. + * @param error - Raw error string or null/undefined + * @param parsedInfo - Optional pre-parsed GitHubErrorInfo to avoid re-classification + */ +export function isNetworkError( + error: string | null | undefined, + parsedInfo?: GitHubErrorInfo | null +): boolean { + if (parsedInfo) return parsedInfo.type === 'network'; + if (!error) return false; + const trimmed = error.trim(); + return classifyError(trimmed, extractStatusCode(trimmed)) === 'network'; +} + +/** + * Check if an error is recoverable (user can retry). + * Rate limit, network, and unknown errors are considered recoverable. + * @param error - Raw error string or null/undefined + * @param parsedInfo - Optional pre-parsed GitHubErrorInfo to avoid re-classification + */ +export function isRecoverableError( + error: string | null | undefined, + parsedInfo?: GitHubErrorInfo | null +): boolean { + if (parsedInfo) return ['rate_limit', 'network', 'unknown'].includes(parsedInfo.type); + if (!error) return false; + const trimmed = error.trim(); + const errorType = classifyError(trimmed, extractStatusCode(trimmed)); + return ['rate_limit', 'network', 'unknown'].includes(errorType); +} + +/** + * Check if an error requires user action in settings. + * Auth and permission errors require settings changes. + * @param error - Raw error string or null/undefined + * @param parsedInfo - Optional pre-parsed GitHubErrorInfo to avoid re-classification + */ +export function requiresSettingsAction( + error: string | null | undefined, + parsedInfo?: GitHubErrorInfo | null +): boolean { + if (parsedInfo) return ['auth', 'permission'].includes(parsedInfo.type); + if (!error) return false; + const trimmed = error.trim(); + const errorType = classifyError(trimmed, extractStatusCode(trimmed)); + return ['auth', 'permission'].includes(errorType); +} diff --git a/apps/frontend/src/renderer/components/github-issues/utils/index.ts b/apps/frontend/src/renderer/components/github-issues/utils/index.ts index 567fa2c3..a2a0e983 100644 --- a/apps/frontend/src/renderer/components/github-issues/utils/index.ts +++ b/apps/frontend/src/renderer/components/github-issues/utils/index.ts @@ -19,3 +19,13 @@ export function filterIssuesBySearch(issues: GitHubIssue[], searchQuery: string) issue.body?.toLowerCase().includes(query) ); } + +// Re-export GitHub error parser utilities +export { + parseGitHubError, + isRateLimitError, + isAuthError, + isNetworkError, + isRecoverableError, + requiresSettingsAction, +} from './github-error-parser'; diff --git a/apps/frontend/src/shared/i18n/locales/en/common.json b/apps/frontend/src/shared/i18n/locales/en/common.json index 8072d04f..d851d091 100644 --- a/apps/frontend/src/shared/i18n/locales/en/common.json +++ b/apps/frontend/src/shared/i18n/locales/en/common.json @@ -659,6 +659,28 @@ "remote": "Remote" } }, + "githubErrors": { + "rateLimitTitle": "GitHub Rate Limit Reached", + "authTitle": "GitHub Authentication Required", + "permissionTitle": "GitHub Permission Denied", + "notFoundTitle": "GitHub Resource Not Found", + "networkTitle": "GitHub Connection Error", + "unknownTitle": "GitHub Error", + "rateLimitMessage": "GitHub API rate limit reached. Please wait a moment before trying again.", + "rateLimitMessageMinutes": "GitHub API rate limit reached. Please wait {{minutes}} minute(s) before trying again.", + "rateLimitMessageHours": "GitHub API rate limit reached. Rate limit resets in approximately {{hours}} hour(s).", + "authMessage": "GitHub authentication failed. Please check your GitHub token in Settings and try again.", + "permissionMessage": "GitHub permission denied. Your token may not have the required access. Please check your token permissions in Settings.", + "permissionMessageScopes": "GitHub permission denied. Your token is missing required scopes: {{scopes}}. Please update your GitHub token in Settings.", + "notFoundMessage": "The requested GitHub resource was not found. Please verify the repository exists and you have access to it.", + "networkMessage": "Unable to connect to GitHub. Please check your internet connection and try again.", + "unknownMessage": "An unexpected error occurred while communicating with GitHub. Please try again.", + "resetsIn": "Resets in {{time}}", + "countdownHoursMinutes": "{{hours}}h {{minutes}}m", + "countdownMinutesSeconds": "{{minutes}}m {{seconds}}s", + "rateLimitExpired": "Rate limit has reset. You can retry now.", + "requiredScopes": "Required scopes" + }, "roadmapProgress": { "elapsedTime": "Elapsed", "lastActivity": "Last activity", diff --git a/apps/frontend/src/shared/i18n/locales/fr/common.json b/apps/frontend/src/shared/i18n/locales/fr/common.json index 3f38ae10..2f861fb9 100644 --- a/apps/frontend/src/shared/i18n/locales/fr/common.json +++ b/apps/frontend/src/shared/i18n/locales/fr/common.json @@ -659,6 +659,28 @@ "remote": "Distante" } }, + "githubErrors": { + "rateLimitTitle": "Limite de débit GitHub atteinte", + "authTitle": "Authentification GitHub requise", + "permissionTitle": "Permission GitHub refusée", + "notFoundTitle": "Ressource GitHub introuvable", + "networkTitle": "Erreur de connexion GitHub", + "unknownTitle": "Erreur GitHub", + "rateLimitMessage": "Limite de débit de l'API GitHub atteinte. Veuillez patienter un moment avant de réessayer.", + "rateLimitMessageMinutes": "Limite de débit de l'API GitHub atteinte. Veuillez attendre {{minutes}} minute(s) avant de réessayer.", + "rateLimitMessageHours": "Limite de débit de l'API GitHub atteinte. La limite se réinitialise dans environ {{hours}} heure(s).", + "authMessage": "Échec de l'authentification GitHub. Veuillez vérifier votre jeton GitHub dans les Paramètres et réessayer.", + "permissionMessage": "Permission GitHub refusée. Votre jeton n'a peut-être pas les accès requis. Veuillez vérifier les permissions de votre jeton dans les Paramètres.", + "permissionMessageScopes": "Permission GitHub refusée. Votre jeton manque de permissions requises : {{scopes}}. Veuillez mettre à jour votre jeton GitHub dans les Paramètres.", + "notFoundMessage": "La ressource GitHub demandée est introuvable. Veuillez vérifier que le dépôt existe et que vous y avez accès.", + "networkMessage": "Impossible de se connecter à GitHub. Veuillez vérifier votre connexion Internet et réessayer.", + "unknownMessage": "Une erreur inattendue s'est produite lors de la communication avec GitHub. Veuillez réessayer.", + "resetsIn": "Réinitialisation dans {{time}}", + "countdownHoursMinutes": "{{hours}}h {{minutes}}m", + "countdownMinutesSeconds": "{{minutes}}m {{seconds}}s", + "rateLimitExpired": "La limite de débit a été réinitialisée. Vous pouvez réessayer maintenant.", + "requiredScopes": "Permissions requises" + }, "roadmapProgress": { "elapsedTime": "Écoulé", "lastActivity": "Dernière activité",