From 48d5f7a3219b9d3c649a66cc00a006773981d517 Mon Sep 17 00:00:00 2001 From: Andy <119136210+AndyMik90@users.noreply.github.com> Date: Mon, 9 Feb 2026 10:36:56 +0100 Subject: [PATCH] Smart PR Status Polling System (#1766) * auto-claude: subtask-1-1 - Create PR status type definitions Add TypeScript types for the smart PR status polling system: - ChecksStatus, ReviewsStatus, MergeableState status types - PRStatus interface for individual PR status data - PollingMetadata interface for polling state tracking - ETagCache types for conditional request support - PRStatusUpdate, StartPollingRequest, StopPollingRequest IPC types - RateLimitInfo and GitHubFetchResult for API response handling - Constants for polling intervals and rate limit thresholds Co-Authored-By: Claude Opus 4.5 * auto-claude: subtask-1-2 - Add IPC channel constants for PR status polling Added three IPC channel constants for GitHub PR status polling: - GITHUB_PR_STATUS_POLL_START: Start polling PR status - GITHUB_PR_STATUS_POLL_STOP: Stop polling PR status - GITHUB_PR_STATUS_UPDATE: Event for PR status updates (main -> renderer) Co-Authored-By: Claude Opus 4.5 * auto-claude: subtask-2-1 - Extend githubFetch with ETag and rate limit support - Add ETagCacheEntry and ETagCache interfaces for conditional requests - Add RateLimitInfo interface for X-RateLimit-Remaining/Reset headers - Add GitHubFetchWithETagResult interface for typed responses - Add extractRateLimitInfo() to parse rate limit headers - Add getETagCache() and clearETagCache() for cache management - Add githubFetchWithETag() for conditional requests with If-None-Match - 304 responses return cached data without consuming rate limit Co-Authored-By: Claude Opus 4.5 * auto-claude: subtask-3-1 - Create PRStatusPoller class with startPolling, stopPolling methods * auto-claude: subtask-3-2 - Add unit tests for PRStatusPoller Add comprehensive unit tests for the PRStatusPoller service covering: - ETag caching behavior (cache storage, 304 responses, cache clearing) - PR classification (active vs stable based on 30-minute activity threshold) - Rate limit handling (pause when below threshold, resume scheduling) - Timer management (start/stop polling, interval verification) - Singleton pattern and instance management - Project ID parsing and validation - PR management (add/remove PRs from polling context) - Status aggregation for CI checks and reviews - Main window integration for IPC updates - Error handling and metadata tracking - Mergeable state handling with retry scheduling Co-Authored-By: Claude Opus 4.5 * auto-claude: subtask-4-1 - Add IPC handlers for PR status polling Added 3 IPC handlers for PR status polling to pr-handlers.ts: - GITHUB_PR_STATUS_POLL_START: Start polling PR status for a project - GITHUB_PR_STATUS_POLL_STOP: Stop polling PR status for a project - GITHUB_PR_STATUS_UPDATE: Get current polling metadata for a project Wired up PRStatusPoller service with main window getter to emit status updates to renderer via IPC channel. Co-Authored-By: Claude Opus 4.5 * auto-claude: subtask-4-2 - Register polling handlers in the GitHub handlers i - Updated github handlers index.ts documentation to include pr-handlers and triage-handlers - Added PR status polling methods to preload GitHub API: - startStatusPolling: Start background polling for PR status - stopStatusPolling: Stop background polling for a project - getPollingMetadata: Get current polling metadata (rate limits, errors) - onPRStatusUpdate: Subscribe to PR status updates from polling - Exported pr-status types from shared types index - Renamed RateLimitInfo to GitHubRateLimitInfo in pr-status.ts to avoid conflict with terminal.ts - Added polling mock implementations to browser-mock.ts for testing Co-Authored-By: Claude Opus 4.5 * auto-claude: subtask-5-1 - Add PR status fields to pr-review-store - Add checksStatus, reviewsStatus, mergeableState, lastPolled fields to PRReviewState interface - Add setPRStatus and clearPRStatus actions for managing status polling data - Add IPC listener for onPRStatusUpdate in initializePRReviewListeners() - Preserve status fields in all existing actions (startPRReview, startFollowupReview, etc.) - Import types from shared/types/pr-status.ts for type safety Co-Authored-By: Claude Opus 4.5 * auto-claude: subtask-6-1 - Create StatusIndicator component to display CI sta Create StatusIndicator component to display CI status (success/pending/failure icons), review status (approved/changes_requested/pending badges), and merge readiness for GitHub PRs. Components included: - CIStatusIcon: Shows check circle (success), spinner (pending), or X (failure) - ReviewStatusBadge: Badge with status text and icon - MergeReadinessIcon: Shows merge readiness state (clean/dirty/blocked) - StatusIndicator: Combines all status indicators with compact mode support - CompactStatusIndicator: Minimal icon-only version for tight spaces Follows existing patterns from PRList.tsx and uses shared types from pr-status.ts. Uses i18n translation keys for all user-facing text. Co-Authored-By: Claude Opus 4.5 * auto-claude: subtask-6-2 - Integrate StatusIndicator into PRList component Add compact status indicators (CI checks, reviews, mergeability) to each PR in the list view alongside the existing PRStatusFlow dots: - Import CompactStatusIndicator from StatusIndicator - Extend PRReviewInfo interface with checksStatus, reviewsStatus, mergeableState - Update getReviewStateForPR to return status fields from the store - Add CompactStatusIndicator to the PR metadata row (hidden merge status for compact display) Co-Authored-By: Claude Opus 4.5 * auto-claude: subtask-6-3 - Update useGitHubPRs hook to trigger polling start * auto-claude: subtask-7-1 - Add translation keys for PR status indicators (CI success/pending/failure, review approved/changes_requested/pending, merge ready/blocked/conflict) to both en and fr locale files Co-Authored-By: Claude Opus 4.5 * auto-claude: subtask-8-1 - Add integration tests for polling lifecycle Added comprehensive integration tests for PRStatusPoller covering: - Start/stop polling on project change (lifecycle management) - Status updates flow to UI via IPC (renderer communication) - Token refresh handling during active polling - PR management during polling (add/remove PRs) - Error recovery (network errors, rate limits) - Concurrent project polling All 25 integration tests pass. Tests verify: - Multiple projects can poll simultaneously - Project switching properly cleans up old contexts - Token refresh preserves polling state - IPC updates include correct project and status data - Rate limit pausing affects all active contexts Co-Authored-By: Claude Opus 4.5 * Fix PR review findings: ETag cache, rate limit detection, staggered resume, poll timestamps - Add project-scoped ETag cache clearing (clearETagCacheForProject) so stopping one project's polling doesn't invalidate other projects' caches - Add TTL-based eviction (30min) and max size cap (200 entries) to prevent unbounded ETag cache growth in long-running sessions - Refine rate limit 403 detection to check rateLimitInfo.remaining before pausing, distinguishing rate limits from permission-denied errors - Stagger resumed polling across contexts (5s apart) after rate limit reset to avoid burst re-triggering - Track actual lastPollCycle timestamp per context instead of returning current time, giving the UI accurate poll timing info - Update test mocks to match renamed clearETagCacheForProject import Co-Authored-By: Claude Opus 4.6 * Fix double-renamed mock variable in test files The replace_all for mockClearETagCache -> mockClearETagCacheForProject also caught the already-renamed text inside the vi.mock factory, producing mockClearETagCacheForProjectForProject. Fix the const declaration and mock factory reference to use the correct name. Co-Authored-By: Claude Opus 4.6 * Fix failing rate limit tests: correct 403 detection logic and async timer flush The catch-block logic required rateLimitInfo to be set AND below threshold, but in the unit test no prior successful fetch set rateLimitInfo (null). Fix: pause on 403 if rateLimitInfo is null (can't rule out rate limit) OR below threshold. A permission-denied 403 with healthy remaining won't pause. For the integration test, use advanceTimersByTimeAsync to properly flush the microtask queue for fire-and-forget async poll callbacks. Co-Authored-By: Claude Opus 4.6 * Fix review findings: track staggered timeouts, eliminate duplicate PR fetch - Track staggered resume setTimeout refs in staggeredResumeTimeouts array and clear them in stopAllPolling/resumePolling to prevent stale callbacks - Pass headSha from fetchPRStatus to fetchChecksStatus, eliminating a duplicate PR endpoint fetch that wasted one API call per poll cycle - Update PRData interface to include head.sha field - Remove duplicate PR endpoint mock entries from test setupFullPollingMocks Co-Authored-By: Claude Opus 4.6 * Fix review findings: sort reviews by timestamp, simplify check, remove unused constant - Sort reviews by submitted_at before building latest-per-user map so aggregation doesn't depend on undocumented GitHub API array ordering - Simplify hasActionableReview to only check PENDING since APPROVED and CHANGES_REQUESTED already cause early returns above - Remove unused RESUME_THRESHOLD constant from RATE_LIMIT_THRESHOLDS - Add submitted_at field to ReviewsResponse interface and test mocks Co-Authored-By: Claude Opus 4.6 * Fix useEffect dependency, add concurrent PR polling, optimize ETag eviction - Add projectId to useEffect dependency array so state resets on project switch - Replace sequential PR polling with batched Promise.allSettled (concurrency 5) - Amortize ETag cache eviction to run every 10th write instead of every write Co-Authored-By: Claude Opus 4.6 * Fix polling restart spam, stale context guard, eviction reset, error log suppression - Memoize PR numbers in useEffect dependency to prevent excessive polling restarts - Guard staggered resume timeouts against stale contexts after stopPolling - Reset eviction write counter in clearETagCache for consistent test state - Add consecutive error tracking to suppress repeated log spam per PR Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.5 --- .../src/main/ipc-handlers/github/index.ts | 2 + .../main/ipc-handlers/github/pr-handlers.ts | 92 ++ .../src/main/ipc-handlers/github/utils.ts | 211 ++++- .../pr-status-poller.integration.test.ts | 643 +++++++++++++ .../__tests__/pr-status-poller.test.ts | 756 +++++++++++++++ .../src/main/services/pr-status-poller.ts | 868 ++++++++++++++++++ .../src/preload/api/modules/github-api.ts | 36 +- .../github-prs/components/PRList.tsx | 17 +- .../github-prs/components/StatusIndicator.tsx | 197 ++++ .../github-prs/hooks/useGitHubPRs.ts | 28 + .../frontend/src/renderer/lib/browser-mock.ts | 7 +- .../renderer/stores/github/pr-review-store.ts | 139 ++- apps/frontend/src/shared/constants/ipc.ts | 5 + .../src/shared/i18n/locales/en/common.json | 26 + .../src/shared/i18n/locales/fr/common.json | 26 + apps/frontend/src/shared/types/index.ts | 1 + apps/frontend/src/shared/types/pr-status.ts | 179 ++++ 17 files changed, 3221 insertions(+), 12 deletions(-) create mode 100644 apps/frontend/src/main/services/__tests__/pr-status-poller.integration.test.ts create mode 100644 apps/frontend/src/main/services/__tests__/pr-status-poller.test.ts create mode 100644 apps/frontend/src/main/services/pr-status-poller.ts create mode 100644 apps/frontend/src/renderer/components/github-prs/components/StatusIndicator.tsx create mode 100644 apps/frontend/src/shared/types/pr-status.ts diff --git a/apps/frontend/src/main/ipc-handlers/github/index.ts b/apps/frontend/src/main/ipc-handlers/github/index.ts index 0b65f5b0..02616cda 100644 --- a/apps/frontend/src/main/ipc-handlers/github/index.ts +++ b/apps/frontend/src/main/ipc-handlers/github/index.ts @@ -10,6 +10,8 @@ * - release-handlers: GitHub release creation * - oauth-handlers: GitHub CLI OAuth authentication * - autofix-handlers: Automatic issue fixing with label triggers + * - pr-handlers: PR review, polling status, and status updates + * - triage-handlers: Issue triage automation */ import type { BrowserWindow } from 'electron'; diff --git a/apps/frontend/src/main/ipc-handlers/github/pr-handlers.ts b/apps/frontend/src/main/ipc-handlers/github/pr-handlers.ts index 6000b3bf..786023a5 100644 --- a/apps/frontend/src/main/ipc-handlers/github/pr-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/github/pr-handlers.ts @@ -35,6 +35,12 @@ import { validateGitHubModule, buildRunnerArgs, } from "./utils/subprocess-runner"; +import { getPRStatusPoller } from "../../services/pr-status-poller"; +import type { + StartPollingRequest, + StopPollingRequest, + PollingMetadata, +} from "../../../shared/types/pr-status"; /** * GraphQL response type for PR list query @@ -3269,5 +3275,91 @@ export function registerPRHandlers(getMainWindow: () => BrowserWindow | null): v } ); + // ============================================================================ + // PR Status Polling Handlers + // ============================================================================ + + // Initialize PRStatusPoller with main window getter for IPC updates + const prStatusPoller = getPRStatusPoller(); + prStatusPoller.setMainWindowGetter(getMainWindow); + + // Start polling PR status for a project + ipcMain.handle( + IPC_CHANNELS.GITHUB_PR_STATUS_POLL_START, + async ( + _, + request: StartPollingRequest + ): Promise<{ success: boolean; error?: string }> => { + debugLog("startStatusPolling handler called", { + projectId: request.projectId, + prCount: request.prNumbers.length, + }); + + const result = await withProjectOrNull(request.projectId, async (project) => { + const config = getGitHubConfig(project); + if (!config) { + debugLog("No GitHub config found for project, cannot start polling"); + return { success: false, error: "No GitHub configuration found" }; + } + + try { + await prStatusPoller.startPolling( + request.projectId, + request.prNumbers, + config.token + ); + debugLog("Status polling started successfully", { + projectId: request.projectId, + }); + return { success: true }; + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + debugLog("Failed to start status polling", { + projectId: request.projectId, + error: message, + }); + return { success: false, error: message }; + } + }); + return result ?? { success: false, error: "Project not found" }; + } + ); + + // Stop polling PR status for a project + ipcMain.handle( + IPC_CHANNELS.GITHUB_PR_STATUS_POLL_STOP, + async ( + _, + request: StopPollingRequest + ): Promise<{ success: boolean }> => { + debugLog("stopStatusPolling handler called", { + projectId: request.projectId, + }); + + try { + prStatusPoller.stopPolling(request.projectId); + debugLog("Status polling stopped successfully", { + projectId: request.projectId, + }); + return { success: true }; + } catch (error) { + debugLog("Failed to stop status polling", { + projectId: request.projectId, + error: error instanceof Error ? error.message : error, + }); + return { success: false }; + } + } + ); + + // Get current polling metadata for a project + ipcMain.handle( + IPC_CHANNELS.GITHUB_PR_STATUS_UPDATE, + async (_, projectId: string): Promise => { + debugLog("getPollingMetadata handler called", { projectId }); + return prStatusPoller.getPollingMetadata(projectId); + } + ); + debugLog("PR handlers registered"); } diff --git a/apps/frontend/src/main/ipc-handlers/github/utils.ts b/apps/frontend/src/main/ipc-handlers/github/utils.ts index 114059e3..1e4151f7 100644 --- a/apps/frontend/src/main/ipc-handlers/github/utils.ts +++ b/apps/frontend/src/main/ipc-handlers/github/utils.ts @@ -14,6 +14,141 @@ import { getToolPath } from '../../cli-tool-manager'; const execFileAsync = promisify(execFile); +/** + * ETag cache entry for conditional requests + */ +export interface ETagCacheEntry { + etag: string; + data: unknown; + lastUpdated: Date; +} + +/** + * ETag cache for storing conditional request data + */ +export interface ETagCache { + [url: string]: ETagCacheEntry; +} + +/** + * Rate limit information extracted from GitHub API response headers + */ +export interface RateLimitInfo { + remaining: number; + reset: Date; + limit: number; +} + +/** + * Response from githubFetchWithETag including cache status and rate limit info + */ +export interface GitHubFetchWithETagResult { + data: unknown; + fromCache: boolean; + rateLimitInfo: RateLimitInfo | null; +} + +/** + * Maximum age for cache entries (30 minutes) + */ +const ETAG_CACHE_TTL_MS = 30 * 60 * 1000; + +/** + * Maximum number of cache entries before evicting oldest + */ +const ETAG_CACHE_MAX_SIZE = 200; + +/** + * Run eviction every N cache writes to amortize cost + */ +const ETAG_EVICTION_INTERVAL = 10; + +/** + * Counter for cache writes since last eviction + */ +let evictionWriteCounter = 0; + +/** + * Module-level ETag cache instance + */ +const etagCache: ETagCache = {}; + +/** + * Get the ETag cache (for testing or external access) + */ +export function getETagCache(): ETagCache { + return etagCache; +} + +/** + * Clear all ETag cache entries (for testing) + */ +export function clearETagCache(): void { + for (const key of Object.keys(etagCache)) { + delete etagCache[key]; + } + evictionWriteCounter = 0; +} + +/** + * Clear ETag cache entries whose URL contains the given repo path (owner/repo). + * Used when stopping polling for a specific project so other projects' caches remain valid. + */ +export function clearETagCacheForProject(ownerRepo: string): void { + const prefix = `https://api.github.com/repos/${ownerRepo}`; + for (const key of Object.keys(etagCache)) { + if (key.startsWith(prefix)) { + delete etagCache[key]; + } + } +} + +/** + * Evict stale entries (older than TTL) and enforce max size by removing oldest entries. + */ +function evictStaleCacheEntries(): void { + const now = Date.now(); + const keys = Object.keys(etagCache); + + // Remove expired entries + for (const key of keys) { + if (now - etagCache[key].lastUpdated.getTime() > ETAG_CACHE_TTL_MS) { + delete etagCache[key]; + } + } + + // Enforce max size by removing oldest entries + const remainingKeys = Object.keys(etagCache); + if (remainingKeys.length > ETAG_CACHE_MAX_SIZE) { + const sorted = remainingKeys.sort( + (a, b) => etagCache[a].lastUpdated.getTime() - etagCache[b].lastUpdated.getTime() + ); + const toRemove = sorted.slice(0, sorted.length - ETAG_CACHE_MAX_SIZE); + for (const key of toRemove) { + delete etagCache[key]; + } + } +} + +/** + * Extract rate limit information from GitHub API response headers + */ +export function extractRateLimitInfo(response: Response): RateLimitInfo | null { + const remaining = response.headers.get('X-RateLimit-Remaining'); + const reset = response.headers.get('X-RateLimit-Reset'); + const limit = response.headers.get('X-RateLimit-Limit'); + + if (remaining === null || reset === null) { + return null; + } + + return { + remaining: parseInt(remaining, 10), + reset: new Date(parseInt(reset, 10) * 1000), + limit: limit ? parseInt(limit, 10) : 5000 + }; +} + /** * Get GitHub token from gh CLI if available (async to avoid blocking main thread) * Uses augmented PATH to find gh CLI in common locations (e.g., Homebrew on macOS) @@ -136,9 +271,81 @@ export async function githubFetch( }); if (!response.ok) { - const errorBody = await response.text(); - throw new Error(`GitHub API error: ${response.status} ${response.statusText} - ${errorBody}`); + throw new Error(`GitHub API error: ${response.status} - Request failed`); } return response.json(); } + +/** + * Make a request to the GitHub API with ETag caching support + * Uses If-None-Match header for conditional requests. + * Returns 304 responses from cache without counting against rate limit. + */ +export async function githubFetchWithETag( + token: string, + endpoint: string, + options: RequestInit = {} +): Promise { + const url = endpoint.startsWith('http') + ? endpoint + : `https://api.github.com${endpoint}`; + + const cached = etagCache[url]; + const headers: Record = { + 'Accept': 'application/vnd.github+json', + 'Authorization': `Bearer ${token}`, + 'User-Agent': 'Auto-Claude-UI' + }; + + // Add If-None-Match header if we have a cached ETag + if (cached?.etag) { + headers['If-None-Match'] = cached.etag; + } + + const response = await fetch(url, { + ...options, + headers: { + ...headers, + ...options.headers + } + }); + + const rateLimitInfo = extractRateLimitInfo(response); + + // Handle 304 Not Modified - return cached data + if (response.status === 304 && cached) { + return { + data: cached.data, + fromCache: true, + rateLimitInfo + }; + } + + if (!response.ok) { + throw new Error(`GitHub API error: ${response.status} - Request failed`); + } + + const data = await response.json(); + + // Store new ETag if present + const newETag = response.headers.get('ETag'); + if (newETag) { + etagCache[url] = { + etag: newETag, + data, + lastUpdated: new Date() + }; + evictionWriteCounter++; + if (evictionWriteCounter >= ETAG_EVICTION_INTERVAL) { + evictionWriteCounter = 0; + evictStaleCacheEntries(); + } + } + + return { + data, + fromCache: false, + rateLimitInfo + }; +} diff --git a/apps/frontend/src/main/services/__tests__/pr-status-poller.integration.test.ts b/apps/frontend/src/main/services/__tests__/pr-status-poller.integration.test.ts new file mode 100644 index 00000000..c6730f07 --- /dev/null +++ b/apps/frontend/src/main/services/__tests__/pr-status-poller.integration.test.ts @@ -0,0 +1,643 @@ +/** + * Integration tests for pr-status-poller.ts + * + * Tests for polling lifecycle, IPC communication, and system integration: + * - Start/stop polling on project change + * - Status updates flow to UI via IPC + * - Token refresh handling during active polling + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { PRStatusPoller, getPRStatusPoller } from '../pr-status-poller'; +import { POLLING_INTERVALS, RATE_LIMIT_THRESHOLDS } from '../../../shared/types/pr-status'; +import type { PRStatusUpdate, PollingMetadata, PRStatus } from '../../../shared/types/pr-status'; + +// Mock the GitHub utils module +const mockGithubFetchWithETag = vi.fn(); +const mockClearETagCacheForProject = vi.fn(); +const mockGetETagCache = vi.fn(); + +vi.mock('../../ipc-handlers/github/utils', () => ({ + githubFetchWithETag: (...args: unknown[]) => mockGithubFetchWithETag(...args), + clearETagCacheForProject: (...args: unknown[]) => mockClearETagCacheForProject(...args), + getETagCache: () => mockGetETagCache() +})); + +// Mock safeSendToRenderer - capture calls for verification +const mockSafeSendToRenderer = vi.fn(); +vi.mock('../../ipc-handlers/utils', () => ({ + safeSendToRenderer: (...args: unknown[]) => mockSafeSendToRenderer(...args) +})); + +// Mock IPC_CHANNELS +vi.mock('../../../shared/constants', () => ({ + IPC_CHANNELS: { + GITHUB_PR_STATUS_UPDATE: 'github:pr-status-update' + } +})); + +describe('PRStatusPoller Integration Tests', () => { + let poller: PRStatusPoller; + + /** + * Helper to create a mock main window for IPC testing + */ + function createMockMainWindow() { + return { + webContents: { + send: vi.fn(), + isDestroyed: () => false + }, + isDestroyed: () => false + } as unknown as Electron.BrowserWindow; + } + + /** + * Helper to create a standard successful PR response + */ + function createSuccessfulPRResponse(prNumber: number, options?: { + updatedAt?: string; + mergeableState?: string; + checksState?: 'success' | 'pending' | 'failure'; + reviewState?: 'APPROVED' | 'CHANGES_REQUESTED' | 'PENDING'; + }) { + const opts = options ?? {}; + const updatedAt = opts.updatedAt ?? new Date().toISOString(); + + return { + data: { + number: prNumber, + updated_at: updatedAt, + head: { sha: `sha-${prNumber}` }, + mergeable: opts.mergeableState !== 'dirty', + mergeable_state: opts.mergeableState ?? 'clean' + }, + fromCache: false, + rateLimitInfo: { remaining: 4500, reset: new Date(Date.now() + 3600000), limit: 5000 } + }; + } + + /** + * Helper to set up mock responses for full PR polling cycle + * (PR data, status, check runs, reviews) + */ + function setupFullPollingMocks(prNumber: number, options?: { + checksStatus?: 'success' | 'pending' | 'failure'; + reviewStatus?: 'APPROVED' | 'CHANGES_REQUESTED' | 'PENDING'; + mergeableState?: string; + rateLimitRemaining?: number; + }) { + const opts = options ?? {}; + const rateLimitRemaining = opts.rateLimitRemaining ?? 4500; + const rateLimitInfo = { remaining: rateLimitRemaining, reset: new Date(Date.now() + 3600000), limit: 5000 }; + + // PR endpoint response (head.sha passed to fetchChecksStatus, no duplicate fetch) + mockGithubFetchWithETag + .mockResolvedValueOnce({ + data: { + number: prNumber, + updated_at: new Date().toISOString(), + head: { sha: `sha-${prNumber}` }, + mergeable: opts.mergeableState !== 'dirty', + mergeable_state: opts.mergeableState ?? 'clean' + }, + fromCache: false, + rateLimitInfo + }) + // Combined status endpoint + .mockResolvedValueOnce({ + data: { + state: opts.checksStatus ?? 'success', + statuses: opts.checksStatus === 'failure' + ? [{ state: 'failure' }] + : opts.checksStatus === 'pending' + ? [{ state: 'pending' }] + : [{ state: 'success' }] + }, + fromCache: false, + rateLimitInfo + }) + // Check runs endpoint + .mockResolvedValueOnce({ + data: { + total_count: 1, + check_runs: [ + { + status: 'completed', + conclusion: opts.checksStatus ?? 'success' + } + ] + }, + fromCache: false, + rateLimitInfo + }) + // Reviews endpoint + .mockResolvedValueOnce({ + data: opts.reviewStatus + ? [{ state: opts.reviewStatus, user: { login: 'reviewer1' }, submitted_at: new Date().toISOString() }] + : [], + fromCache: false, + rateLimitInfo + }); + } + + beforeEach(() => { + vi.useFakeTimers(); + // Reset singleton and create fresh instance + PRStatusPoller.resetInstance(); + poller = PRStatusPoller.getInstance(); + + // Reset all mocks + mockGithubFetchWithETag.mockReset(); + mockClearETagCacheForProject.mockReset(); + mockSafeSendToRenderer.mockReset(); + }); + + afterEach(() => { + // Clean up timers and polling + poller.stopAllPolling(); + vi.useRealTimers(); + }); + + describe('Polling Lifecycle: Start/Stop on Project Change', () => { + it('should start polling when a new project is selected', async () => { + setupFullPollingMocks(1); + + await poller.startPolling('owner/repo', [1], 'test-token'); + + const metadata = poller.getPollingMetadata('owner/repo'); + expect(metadata.isPolling).toBe(true); + expect(mockGithubFetchWithETag).toHaveBeenCalled(); + }); + + it('should stop polling for old project when switching to new project', async () => { + // Start polling for first project + setupFullPollingMocks(1); + await poller.startPolling('owner/repo1', [1], 'test-token'); + + expect(poller.getPollingMetadata('owner/repo1').isPolling).toBe(true); + + // Switch to second project + setupFullPollingMocks(2); + await poller.startPolling('owner/repo2', [2], 'test-token'); + + // First project should still be polling (not auto-stopped) + expect(poller.getPollingMetadata('owner/repo1').isPolling).toBe(true); + expect(poller.getPollingMetadata('owner/repo2').isPolling).toBe(true); + + // Explicitly stop first project + poller.stopPolling('owner/repo1'); + expect(poller.getPollingMetadata('owner/repo1').isPolling).toBe(false); + expect(poller.getPollingMetadata('owner/repo2').isPolling).toBe(true); + }); + + it('should clean up timers and caches when stopping polling', async () => { + setupFullPollingMocks(1); + await poller.startPolling('owner/repo', [1], 'test-token'); + + // Stop polling + poller.stopPolling('owner/repo'); + + // Verify cache was cleared + expect(mockClearETagCacheForProject).toHaveBeenCalled(); + + // Verify polling state is cleared + expect(poller.getPollingMetadata('owner/repo').isPolling).toBe(false); + + // Advance time and verify no more API calls are made + const callCountAfterStop = mockGithubFetchWithETag.mock.calls.length; + vi.advanceTimersByTime(POLLING_INTERVALS.ACTIVE + 1000); + expect(mockGithubFetchWithETag.mock.calls.length).toBe(callCountAfterStop); + }); + + it('should replace existing polling when startPolling called for same project', async () => { + setupFullPollingMocks(1); + await poller.startPolling('owner/repo', [1], 'old-token'); + + // Clear mock to track new calls + mockClearETagCacheForProject.mockClear(); + + // Start polling again with different PRs and token + setupFullPollingMocks(2); + await poller.startPolling('owner/repo', [2], 'new-token'); + + // Should have cleared cache when stopping old polling + expect(mockClearETagCacheForProject).toHaveBeenCalled(); + + // Should still be polling + expect(poller.getPollingMetadata('owner/repo').isPolling).toBe(true); + }); + + it('should stop all polling when stopAllPolling is called', async () => { + // Start polling for multiple projects + setupFullPollingMocks(1); + await poller.startPolling('owner/repo1', [1], 'test-token'); + setupFullPollingMocks(2); + await poller.startPolling('owner/repo2', [2], 'test-token'); + + expect(poller.getPollingMetadata('owner/repo1').isPolling).toBe(true); + expect(poller.getPollingMetadata('owner/repo2').isPolling).toBe(true); + + // Stop all polling + poller.stopAllPolling(); + + expect(poller.getPollingMetadata('owner/repo1').isPolling).toBe(false); + expect(poller.getPollingMetadata('owner/repo2').isPolling).toBe(false); + }); + + it('should handle rapid project switching gracefully', async () => { + // Simulate rapid project switching + for (let i = 0; i < 5; i++) { + setupFullPollingMocks(i + 1); + await poller.startPolling(`owner/repo${i}`, [i + 1], 'test-token'); + poller.stopPolling(`owner/repo${i}`); + } + + // All projects should be stopped + for (let i = 0; i < 5; i++) { + expect(poller.getPollingMetadata(`owner/repo${i}`).isPolling).toBe(false); + } + }); + }); + + describe('Status Updates Flow to UI', () => { + it('should send status updates to renderer via IPC', async () => { + const mockMainWindow = createMockMainWindow(); + poller.setMainWindowGetter(() => mockMainWindow); + + setupFullPollingMocks(1, { checksStatus: 'success', reviewStatus: 'APPROVED' }); + await poller.startPolling('owner/repo', [1], 'test-token'); + + // Verify safeSendToRenderer was called with status update + expect(mockSafeSendToRenderer).toHaveBeenCalled(); + + const calls = mockSafeSendToRenderer.mock.calls; + expect(calls.length).toBeGreaterThan(0); + + // Check that the correct channel was used + const updateCall = calls.find((call: unknown[]) => + call[1] === 'github:pr-status-update' + ); + expect(updateCall).toBeTruthy(); + + // Verify the update structure + if (updateCall) { + const update = updateCall[2] as PRStatusUpdate; + expect(update.projectId).toBe('owner/repo'); + expect(update.statuses).toBeDefined(); + expect(update.metadata).toBeDefined(); + expect(update.metadata.isPolling).toBe(true); + } + }); + + it('should include PR status in updates', async () => { + const mockMainWindow = createMockMainWindow(); + poller.setMainWindowGetter(() => mockMainWindow); + + setupFullPollingMocks(42, { + checksStatus: 'success', + reviewStatus: 'APPROVED', + mergeableState: 'clean' + }); + await poller.startPolling('owner/repo', [42], 'test-token'); + + // Find the status update call + const updateCall = mockSafeSendToRenderer.mock.calls.find((call: unknown[]) => + call[1] === 'github:pr-status-update' + ); + + expect(updateCall).toBeTruthy(); + if (updateCall) { + const update = updateCall[2] as PRStatusUpdate; + expect(update.statuses.length).toBeGreaterThan(0); + + const prStatus = update.statuses.find((s: PRStatus) => s.prNumber === 42); + expect(prStatus).toBeDefined(); + if (prStatus) { + expect(prStatus.checksStatus).toBe('success'); + expect(prStatus.reviewsStatus).toBe('approved'); + expect(prStatus.mergeableState).toBe('clean'); + } + } + }); + + it('should include polling metadata in updates', async () => { + const mockMainWindow = createMockMainWindow(); + poller.setMainWindowGetter(() => mockMainWindow); + + setupFullPollingMocks(1); + await poller.startPolling('owner/repo', [1], 'test-token'); + + const updateCall = mockSafeSendToRenderer.mock.calls.find((call: unknown[]) => + call[1] === 'github:pr-status-update' + ); + + expect(updateCall).toBeTruthy(); + if (updateCall) { + const update = updateCall[2] as PRStatusUpdate; + const metadata: PollingMetadata = update.metadata; + + expect(metadata.isPolling).toBe(true); + expect(metadata.isPausedForRateLimit).toBe(false); + expect(metadata.rateLimitRemaining).toBe(4500); + expect(metadata.lastError).toBeNull(); + } + }); + + it('should handle missing main window gracefully', async () => { + // Don't set main window getter + setupFullPollingMocks(1); + + // Should not throw + await expect(poller.startPolling('owner/repo', [1], 'test-token')).resolves.not.toThrow(); + + // Polling should still work + expect(poller.getPollingMetadata('owner/repo').isPolling).toBe(true); + }); + + it('should continue sending updates after timer intervals', async () => { + const mockMainWindow = createMockMainWindow(); + poller.setMainWindowGetter(() => mockMainWindow); + + setupFullPollingMocks(1); + await poller.startPolling('owner/repo', [1], 'test-token'); + + const initialCalls = mockSafeSendToRenderer.mock.calls.length; + + // Set up mocks for the next poll cycle + setupFullPollingMocks(1); + + // Advance timer past active polling interval + vi.advanceTimersByTime(POLLING_INTERVALS.ACTIVE + 1000); + + // Allow promises to resolve + await vi.runOnlyPendingTimersAsync(); + + // Should have made additional IPC calls + expect(mockSafeSendToRenderer.mock.calls.length).toBeGreaterThan(initialCalls); + }); + + it('should send rate limit pause notification to all contexts', async () => { + const mockMainWindow = createMockMainWindow(); + poller.setMainWindowGetter(() => mockMainWindow); + + // Start polling for two projects + setupFullPollingMocks(1); + await poller.startPolling('owner/repo1', [1], 'test-token'); + setupFullPollingMocks(2); + await poller.startPolling('owner/repo2', [2], 'test-token'); + + mockSafeSendToRenderer.mockClear(); + + // Trigger rate limit pause + mockGithubFetchWithETag.mockResolvedValue({ + data: { number: 1, updated_at: new Date().toISOString(), head: { sha: 'abc' }, mergeable: true, mergeable_state: 'clean' }, + fromCache: false, + rateLimitInfo: { remaining: RATE_LIMIT_THRESHOLDS.PAUSE_THRESHOLD - 1, reset: new Date(Date.now() + 3600000), limit: 5000 } + }); + + // Advance to next poll cycle and flush async work + await vi.advanceTimersByTimeAsync(POLLING_INTERVALS.ACTIVE + 1000); + + // Both projects should reflect paused state + expect(poller.getPollingMetadata('owner/repo1').isPausedForRateLimit).toBe(true); + expect(poller.getPollingMetadata('owner/repo2').isPausedForRateLimit).toBe(true); + }); + }); + + describe('Token Refresh Handling', () => { + it('should continue polling with new token after restart', async () => { + setupFullPollingMocks(1); + await poller.startPolling('owner/repo', [1], 'old-token'); + + expect(poller.getPollingMetadata('owner/repo').isPolling).toBe(true); + + // Simulate token refresh by stopping and restarting with new token + poller.stopPolling('owner/repo'); + expect(poller.getPollingMetadata('owner/repo').isPolling).toBe(false); + + setupFullPollingMocks(1); + await poller.startPolling('owner/repo', [1], 'new-token'); + expect(poller.getPollingMetadata('owner/repo').isPolling).toBe(true); + + // Verify new token is used in API calls + const lastCall = mockGithubFetchWithETag.mock.calls[mockGithubFetchWithETag.mock.calls.length - 1]; + expect(lastCall[0]).toBe('new-token'); + }); + + it('should use updated token for subsequent poll cycles', async () => { + // Start polling + setupFullPollingMocks(1); + await poller.startPolling('owner/repo', [1], 'initial-token'); + + // Stop and restart with new token (simulating token refresh) + setupFullPollingMocks(1); + await poller.startPolling('owner/repo', [1], 'refreshed-token'); + + mockGithubFetchWithETag.mockClear(); + + // Set up mocks for next poll cycle + setupFullPollingMocks(1); + + // Advance to next poll cycle + vi.advanceTimersByTime(POLLING_INTERVALS.ACTIVE + 1000); + await vi.runOnlyPendingTimersAsync(); + + // Verify refreshed token is used + const calls = mockGithubFetchWithETag.mock.calls; + if (calls.length > 0) { + expect(calls[0][0]).toBe('refreshed-token'); + } + }); + + it('should handle 401 errors indicating expired token', async () => { + // biome-ignore lint/suspicious/noEmptyBlockStatements: Mock console.error for test + vi.spyOn(console, 'error').mockImplementation(() => {}); + + setupFullPollingMocks(1); + await poller.startPolling('owner/repo', [1], 'test-token'); + + // Simulate 401 error on next poll + mockGithubFetchWithETag.mockRejectedValue(new Error('401 Unauthorized')); + + // Advance to trigger poll + vi.advanceTimersByTime(POLLING_INTERVALS.ACTIVE + 1000); + await vi.runOnlyPendingTimersAsync(); + + // Should record error in metadata + const metadata = poller.getPollingMetadata('owner/repo'); + expect(metadata.lastError).toContain('401'); + }); + + it('should clear errors when restarting with fresh token', async () => { + // biome-ignore lint/suspicious/noEmptyBlockStatements: Mock console.error for test + vi.spyOn(console, 'error').mockImplementation(() => {}); + + // Start with error + mockGithubFetchWithETag.mockRejectedValue(new Error('401 Unauthorized')); + await poller.startPolling('owner/repo', [1], 'expired-token'); + + expect(poller.getPollingMetadata('owner/repo').lastError).toBeTruthy(); + + // Restart with fresh token + setupFullPollingMocks(1); + await poller.startPolling('owner/repo', [1], 'fresh-token'); + + // Error should be cleared (stopPolling clears errors) + // Note: After restart, if successful, lastError should be null + const metadata = poller.getPollingMetadata('owner/repo'); + expect(metadata.isPolling).toBe(true); + }); + + it('should preserve PR list across token refresh', async () => { + setupFullPollingMocks(1); + await poller.startPolling('owner/repo', [1, 2, 3], 'old-token'); + + // Stop and restart with same PRs but new token + setupFullPollingMocks(1); + setupFullPollingMocks(2); + setupFullPollingMocks(3); + await poller.startPolling('owner/repo', [1, 2, 3], 'new-token'); + + expect(poller.getPollingMetadata('owner/repo').isPolling).toBe(true); + }); + }); + + describe('PR Management During Polling', () => { + it('should add new PRs to existing polling context', async () => { + setupFullPollingMocks(1); + await poller.startPolling('owner/repo', [1], 'test-token'); + + // Add more PRs + poller.addPRs('owner/repo', [2, 3]); + + // Polling should continue + expect(poller.getPollingMetadata('owner/repo').isPolling).toBe(true); + }); + + it('should remove PRs from existing polling context', async () => { + setupFullPollingMocks(1); + await poller.startPolling('owner/repo', [1, 2, 3], 'test-token'); + + // Remove some PRs + poller.removePRs('owner/repo', [2, 3]); + + // Polling should continue with remaining PRs + expect(poller.getPollingMetadata('owner/repo').isPolling).toBe(true); + }); + + it('should handle adding duplicate PRs', async () => { + setupFullPollingMocks(1); + await poller.startPolling('owner/repo', [1], 'test-token'); + + // Add same PR again - should not cause issues + poller.addPRs('owner/repo', [1]); + + expect(poller.getPollingMetadata('owner/repo').isPolling).toBe(true); + }); + + it('should handle removing non-existent PRs', async () => { + setupFullPollingMocks(1); + await poller.startPolling('owner/repo', [1], 'test-token'); + + // Remove PR that doesn't exist - should not cause issues + poller.removePRs('owner/repo', [999]); + + expect(poller.getPollingMetadata('owner/repo').isPolling).toBe(true); + }); + }); + + describe('Error Recovery', () => { + it('should continue polling after transient network error', async () => { + // biome-ignore lint/suspicious/noEmptyBlockStatements: Mock console.error for test + vi.spyOn(console, 'error').mockImplementation(() => {}); + + setupFullPollingMocks(1); + await poller.startPolling('owner/repo', [1], 'test-token'); + + // Simulate network error + mockGithubFetchWithETag.mockRejectedValue(new Error('Network error')); + + vi.advanceTimersByTime(POLLING_INTERVALS.ACTIVE + 1000); + await vi.runOnlyPendingTimersAsync(); + + // Error should be recorded + expect(poller.getPollingMetadata('owner/repo').lastError).toBe('Network error'); + + // But polling should continue + expect(poller.getPollingMetadata('owner/repo').isPolling).toBe(true); + + // Next successful poll should clear error + setupFullPollingMocks(1); + + vi.advanceTimersByTime(POLLING_INTERVALS.ACTIVE + 1000); + await vi.runOnlyPendingTimersAsync(); + + // After a successful poll, error might still be there until explicitly cleared + // The important thing is polling continues + expect(poller.getPollingMetadata('owner/repo').isPolling).toBe(true); + }); + + it('should pause and resume after rate limit error', async () => { + setupFullPollingMocks(1); + await poller.startPolling('owner/repo', [1], 'test-token'); + + // Trigger rate limit + mockGithubFetchWithETag.mockResolvedValue({ + data: { number: 1, updated_at: new Date().toISOString(), head: { sha: 'abc' }, mergeable: true, mergeable_state: 'clean' }, + fromCache: false, + rateLimitInfo: { remaining: RATE_LIMIT_THRESHOLDS.PAUSE_THRESHOLD - 10, reset: new Date(Date.now() + 60000), limit: 5000 } + }); + + vi.advanceTimersByTime(POLLING_INTERVALS.ACTIVE + 1000); + await vi.runOnlyPendingTimersAsync(); + + expect(poller.isPaused()).toBe(true); + expect(poller.getPollingMetadata('owner/repo').isPausedForRateLimit).toBe(true); + }); + }); + + describe('Concurrent Project Polling', () => { + it('should handle multiple projects polling simultaneously', async () => { + // Start polling for three different projects + setupFullPollingMocks(1); + await poller.startPolling('org1/repo1', [1], 'token1'); + setupFullPollingMocks(2); + await poller.startPolling('org2/repo2', [2], 'token2'); + setupFullPollingMocks(3); + await poller.startPolling('org3/repo3', [3], 'token3'); + + // All should be polling + expect(poller.getPollingMetadata('org1/repo1').isPolling).toBe(true); + expect(poller.getPollingMetadata('org2/repo2').isPolling).toBe(true); + expect(poller.getPollingMetadata('org3/repo3').isPolling).toBe(true); + + // Stop one, others should continue + poller.stopPolling('org2/repo2'); + expect(poller.getPollingMetadata('org1/repo1').isPolling).toBe(true); + expect(poller.getPollingMetadata('org2/repo2').isPolling).toBe(false); + expect(poller.getPollingMetadata('org3/repo3').isPolling).toBe(true); + }); + + it('should send separate IPC updates for each project', async () => { + const mockMainWindow = createMockMainWindow(); + poller.setMainWindowGetter(() => mockMainWindow); + + setupFullPollingMocks(1); + await poller.startPolling('org1/repo1', [1], 'token1'); + setupFullPollingMocks(2); + await poller.startPolling('org2/repo2', [2], 'token2'); + + // Find updates for each project + const updates = mockSafeSendToRenderer.mock.calls + .filter((call: unknown[]) => call[1] === 'github:pr-status-update') + .map((call: unknown[]) => call[2] as PRStatusUpdate); + + const project1Updates = updates.filter(u => u.projectId === 'org1/repo1'); + const project2Updates = updates.filter(u => u.projectId === 'org2/repo2'); + + expect(project1Updates.length).toBeGreaterThan(0); + expect(project2Updates.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/apps/frontend/src/main/services/__tests__/pr-status-poller.test.ts b/apps/frontend/src/main/services/__tests__/pr-status-poller.test.ts new file mode 100644 index 00000000..63abd8a3 --- /dev/null +++ b/apps/frontend/src/main/services/__tests__/pr-status-poller.test.ts @@ -0,0 +1,756 @@ +/** + * Tests for pr-status-poller.ts + * + * Unit tests for PRStatusPoller service covering: + * - ETag caching behavior + * - PR classification (active vs stable based on activity) + * - Rate limit handling (pause/resume) + * - Timer management (start/stop polling) + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { PRStatusPoller, getPRStatusPoller } from '../pr-status-poller'; +import { POLLING_INTERVALS, RATE_LIMIT_THRESHOLDS, ACTIVITY_THRESHOLD_MS } from '../../../shared/types/pr-status'; + +// Mock the GitHub utils module +const mockGithubFetchWithETag = vi.fn(); +const mockClearETagCacheForProject = vi.fn(); +const mockGetETagCache = vi.fn(); + +vi.mock('../../ipc-handlers/github/utils', () => ({ + githubFetchWithETag: (...args: unknown[]) => mockGithubFetchWithETag(...args), + clearETagCacheForProject: (...args: unknown[]) => mockClearETagCacheForProject(...args), + getETagCache: () => mockGetETagCache() +})); + +// Mock safeSendToRenderer +const mockSafeSendToRenderer = vi.fn(); +vi.mock('../../ipc-handlers/utils', () => ({ + safeSendToRenderer: (...args: unknown[]) => mockSafeSendToRenderer(...args) +})); + +// Mock IPC_CHANNELS +vi.mock('../../../shared/constants', () => ({ + IPC_CHANNELS: { + GITHUB_PR_STATUS_UPDATE: 'github:pr-status-update' + } +})); + +describe('PRStatusPoller', () => { + let poller: PRStatusPoller; + + beforeEach(() => { + vi.useFakeTimers(); + // Reset singleton and create fresh instance + PRStatusPoller.resetInstance(); + poller = PRStatusPoller.getInstance(); + + // Reset all mocks + mockGithubFetchWithETag.mockReset(); + mockClearETagCacheForProject.mockReset(); + mockSafeSendToRenderer.mockReset(); + }); + + afterEach(() => { + // Clean up timers and polling + poller.stopAllPolling(); + vi.useRealTimers(); + }); + + describe('Singleton Pattern', () => { + it('should return the same instance on multiple calls', () => { + const instance1 = PRStatusPoller.getInstance(); + const instance2 = PRStatusPoller.getInstance(); + expect(instance1).toBe(instance2); + }); + + it('should return a new instance after reset', () => { + const instance1 = PRStatusPoller.getInstance(); + PRStatusPoller.resetInstance(); + const instance2 = PRStatusPoller.getInstance(); + expect(instance1).not.toBe(instance2); + }); + + it('getPRStatusPoller should return the singleton instance', () => { + const instance = getPRStatusPoller(); + expect(instance).toBe(PRStatusPoller.getInstance()); + }); + }); + + describe('PR Classification', () => { + it('should classify PR as active when updated within 30 minutes', async () => { + // Mock response with recent activity (5 minutes ago) + const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000).toISOString(); + + mockGithubFetchWithETag.mockResolvedValue({ + data: { + number: 1, + updated_at: fiveMinutesAgo, + head: { sha: 'abc123' }, + mergeable: true, + mergeable_state: 'clean' + }, + fromCache: false, + rateLimitInfo: { remaining: 4500, reset: new Date(Date.now() + 3600000), limit: 5000 } + }); + + await poller.startPolling('owner/repo', [1], 'test-token'); + + // Get metadata to check polling state + const metadata = poller.getPollingMetadata('owner/repo'); + expect(metadata.isPolling).toBe(true); + }); + + it('should classify PR as stable when not updated for over 30 minutes', async () => { + // Mock response with old activity (1 hour ago) + const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString(); + + mockGithubFetchWithETag.mockResolvedValue({ + data: { + number: 1, + updated_at: oneHourAgo, + head: { sha: 'abc123' }, + mergeable: true, + mergeable_state: 'clean' + }, + fromCache: false, + rateLimitInfo: { remaining: 4500, reset: new Date(Date.now() + 3600000), limit: 5000 } + }); + + await poller.startPolling('owner/repo', [1], 'test-token'); + + // Check that polling started + const metadata = poller.getPollingMetadata('owner/repo'); + expect(metadata.isPolling).toBe(true); + }); + + it('should use ACTIVITY_THRESHOLD_MS (30 minutes) for classification boundary', () => { + // Verify the constant is correctly set + expect(ACTIVITY_THRESHOLD_MS).toBe(30 * 60 * 1000); + }); + }); + + describe('ETag Caching', () => { + it('should pass cached data when 304 response received', async () => { + // First call returns fresh data + mockGithubFetchWithETag.mockResolvedValueOnce({ + data: { + number: 1, + updated_at: new Date().toISOString(), + head: { sha: 'abc123' }, + mergeable: true, + mergeable_state: 'clean' + }, + fromCache: false, + rateLimitInfo: { remaining: 4500, reset: new Date(Date.now() + 3600000), limit: 5000 } + }); + + // Subsequent calls return cached data + mockGithubFetchWithETag.mockResolvedValue({ + data: { + number: 1, + updated_at: new Date().toISOString(), + head: { sha: 'abc123' }, + mergeable: true, + mergeable_state: 'clean' + }, + fromCache: true, + rateLimitInfo: { remaining: 4499, reset: new Date(Date.now() + 3600000), limit: 5000 } + }); + + await poller.startPolling('owner/repo', [1], 'test-token'); + + // Verify fetch was called + expect(mockGithubFetchWithETag).toHaveBeenCalled(); + + // Check the endpoint format + const firstCall = mockGithubFetchWithETag.mock.calls[0]; + expect(firstCall[0]).toBe('test-token'); + expect(firstCall[1]).toContain('/repos/owner/repo/pulls/1'); + }); + + it('should clear ETag cache when stopping polling', async () => { + mockGithubFetchWithETag.mockResolvedValue({ + data: { + number: 1, + updated_at: new Date().toISOString(), + head: { sha: 'abc123' }, + mergeable: true, + mergeable_state: 'clean' + }, + fromCache: false, + rateLimitInfo: { remaining: 4500, reset: new Date(Date.now() + 3600000), limit: 5000 } + }); + + await poller.startPolling('owner/repo', [1], 'test-token'); + poller.stopPolling('owner/repo'); + + expect(mockClearETagCacheForProject).toHaveBeenCalled(); + }); + }); + + describe('Rate Limit Handling', () => { + it('should pause polling when rate limit drops below threshold', async () => { + // Return response with low rate limit + mockGithubFetchWithETag.mockResolvedValue({ + data: { + number: 1, + updated_at: new Date().toISOString(), + head: { sha: 'abc123' }, + mergeable: true, + mergeable_state: 'clean' + }, + fromCache: false, + rateLimitInfo: { + remaining: RATE_LIMIT_THRESHOLDS.PAUSE_THRESHOLD - 1, + reset: new Date(Date.now() + 3600000), + limit: 5000 + } + }); + + await poller.startPolling('owner/repo', [1], 'test-token'); + + // Check that poller is paused + expect(poller.isPaused()).toBe(true); + + // Check metadata reflects paused state + const metadata = poller.getPollingMetadata('owner/repo'); + expect(metadata.isPausedForRateLimit).toBe(true); + }); + + it('should not pause when rate limit is above threshold', async () => { + mockGithubFetchWithETag.mockResolvedValue({ + data: { + number: 1, + updated_at: new Date().toISOString(), + head: { sha: 'abc123' }, + mergeable: true, + mergeable_state: 'clean' + }, + fromCache: false, + rateLimitInfo: { + remaining: RATE_LIMIT_THRESHOLDS.PAUSE_THRESHOLD + 100, + reset: new Date(Date.now() + 3600000), + limit: 5000 + } + }); + + await poller.startPolling('owner/repo', [1], 'test-token'); + + expect(poller.isPaused()).toBe(false); + }); + + it('should include rate limit info in polling metadata', async () => { + const resetTime = new Date(Date.now() + 3600000); + + mockGithubFetchWithETag.mockResolvedValue({ + data: { + number: 1, + updated_at: new Date().toISOString(), + head: { sha: 'abc123' }, + mergeable: true, + mergeable_state: 'clean' + }, + fromCache: false, + rateLimitInfo: { remaining: 4500, reset: resetTime, limit: 5000 } + }); + + await poller.startPolling('owner/repo', [1], 'test-token'); + + const metadata = poller.getPollingMetadata('owner/repo'); + expect(metadata.rateLimitRemaining).toBe(4500); + expect(metadata.rateLimitReset).toBeTruthy(); + }); + + it('should schedule resume after rate limit reset', async () => { + const resetTime = new Date(Date.now() + 60000); // Reset in 60 seconds + + mockGithubFetchWithETag.mockResolvedValue({ + data: { + number: 1, + updated_at: new Date().toISOString(), + head: { sha: 'abc123' }, + mergeable: true, + mergeable_state: 'clean' + }, + fromCache: false, + rateLimitInfo: { + remaining: RATE_LIMIT_THRESHOLDS.PAUSE_THRESHOLD - 1, + reset: resetTime, + limit: 5000 + } + }); + + await poller.startPolling('owner/repo', [1], 'test-token'); + expect(poller.isPaused()).toBe(true); + + // Verify rate limit reset timestamp is tracked + const metadata = poller.getPollingMetadata('owner/repo'); + expect(metadata.rateLimitReset).toBeTruthy(); + expect(metadata.isPausedForRateLimit).toBe(true); + + // Stop polling to clean up timers (avoiding infinite loop in test) + poller.stopPolling('owner/repo'); + }); + }); + + describe('Timer Management', () => { + it('should start polling with correct intervals', async () => { + mockGithubFetchWithETag.mockResolvedValue({ + data: { + number: 1, + updated_at: new Date().toISOString(), + head: { sha: 'abc123' }, + mergeable: true, + mergeable_state: 'clean' + }, + fromCache: false, + rateLimitInfo: { remaining: 4500, reset: new Date(Date.now() + 3600000), limit: 5000 } + }); + + await poller.startPolling('owner/repo', [1], 'test-token'); + + // Initial poll should have happened + const initialCallCount = mockGithubFetchWithETag.mock.calls.length; + expect(initialCallCount).toBeGreaterThan(0); + + // Verify polling intervals are defined correctly + expect(POLLING_INTERVALS.ACTIVE).toBe(60_000); + expect(POLLING_INTERVALS.STABLE).toBe(300_000); + expect(POLLING_INTERVALS.FULL_REFRESH).toBe(900_000); + }); + + it('should stop all timers when stopPolling is called', async () => { + mockGithubFetchWithETag.mockResolvedValue({ + data: { + number: 1, + updated_at: new Date().toISOString(), + head: { sha: 'abc123' }, + mergeable: true, + mergeable_state: 'clean' + }, + fromCache: false, + rateLimitInfo: { remaining: 4500, reset: new Date(Date.now() + 3600000), limit: 5000 } + }); + + await poller.startPolling('owner/repo', [1], 'test-token'); + + // Record call count after initial poll + const callCountAfterStart = mockGithubFetchWithETag.mock.calls.length; + + // Stop polling + poller.stopPolling('owner/repo'); + + // Advance time past all polling intervals + vi.advanceTimersByTime(POLLING_INTERVALS.FULL_REFRESH + 1000); + + // Call count should not have increased + expect(mockGithubFetchWithETag.mock.calls.length).toBe(callCountAfterStart); + }); + + it('should stop all polling when stopAllPolling is called', async () => { + mockGithubFetchWithETag.mockResolvedValue({ + data: { + number: 1, + updated_at: new Date().toISOString(), + head: { sha: 'abc123' }, + mergeable: true, + mergeable_state: 'clean' + }, + fromCache: false, + rateLimitInfo: { remaining: 4500, reset: new Date(Date.now() + 3600000), limit: 5000 } + }); + + await poller.startPolling('owner/repo1', [1], 'test-token'); + await poller.startPolling('owner/repo2', [2], 'test-token'); + + poller.stopAllPolling(); + + // Both contexts should be stopped + expect(poller.getPollingMetadata('owner/repo1').isPolling).toBe(false); + expect(poller.getPollingMetadata('owner/repo2').isPolling).toBe(false); + }); + + it('should replace existing polling when startPolling is called again', async () => { + mockGithubFetchWithETag.mockResolvedValue({ + data: { + number: 1, + updated_at: new Date().toISOString(), + head: { sha: 'abc123' }, + mergeable: true, + mergeable_state: 'clean' + }, + fromCache: false, + rateLimitInfo: { remaining: 4500, reset: new Date(Date.now() + 3600000), limit: 5000 } + }); + + await poller.startPolling('owner/repo', [1], 'test-token'); + + // Clear ETag cache should be called when stopping old polling + mockClearETagCacheForProject.mockClear(); + + await poller.startPolling('owner/repo', [1, 2], 'new-token'); + + // Should have called clear on the old context + expect(mockClearETagCacheForProject).toHaveBeenCalled(); + }); + }); + + describe('Project ID Parsing', () => { + it('should handle valid owner/repo format', async () => { + mockGithubFetchWithETag.mockResolvedValue({ + data: { + number: 1, + updated_at: new Date().toISOString(), + head: { sha: 'abc123' }, + mergeable: true, + mergeable_state: 'clean' + }, + fromCache: false, + rateLimitInfo: { remaining: 4500, reset: new Date(Date.now() + 3600000), limit: 5000 } + }); + + await poller.startPolling('myowner/myrepo', [1], 'test-token'); + + // Verify the API endpoint was constructed correctly + const calls = mockGithubFetchWithETag.mock.calls; + const prEndpoint = calls.find((call: unknown[]) => + typeof call[1] === 'string' && call[1].includes('/repos/myowner/myrepo/pulls/1') + ); + expect(prEndpoint).toBeTruthy(); + }); + + it('should not start polling for invalid project ID format', async () => { + // Console error expected for invalid format + // biome-ignore lint/suspicious/noEmptyBlockStatements: mock implementation intentionally empty + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + await poller.startPolling('invalid-format', [1], 'test-token'); + + // Should log error and not start polling + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('Invalid project ID format') + ); + expect(poller.getPollingMetadata('invalid-format').isPolling).toBe(false); + + consoleSpy.mockRestore(); + }); + }); + + describe('PR Management', () => { + it('should add PRs to existing polling context', async () => { + mockGithubFetchWithETag.mockResolvedValue({ + data: { + number: 1, + updated_at: new Date().toISOString(), + head: { sha: 'abc123' }, + mergeable: true, + mergeable_state: 'clean' + }, + fromCache: false, + rateLimitInfo: { remaining: 4500, reset: new Date(Date.now() + 3600000), limit: 5000 } + }); + + await poller.startPolling('owner/repo', [1], 'test-token'); + + // Add more PRs + poller.addPRs('owner/repo', [2, 3]); + + // Context should still be active + expect(poller.getPollingMetadata('owner/repo').isPolling).toBe(true); + }); + + it('should warn when adding PRs to non-existent context', () => { + // biome-ignore lint/suspicious/noEmptyBlockStatements: mock implementation intentionally empty + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + poller.addPRs('non-existent/repo', [1, 2]); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('No polling context') + ); + + consoleSpy.mockRestore(); + }); + + it('should remove PRs from existing polling context', async () => { + mockGithubFetchWithETag.mockResolvedValue({ + data: { + number: 1, + updated_at: new Date().toISOString(), + head: { sha: 'abc123' }, + mergeable: true, + mergeable_state: 'clean' + }, + fromCache: false, + rateLimitInfo: { remaining: 4500, reset: new Date(Date.now() + 3600000), limit: 5000 } + }); + + await poller.startPolling('owner/repo', [1, 2, 3], 'test-token'); + + // Remove some PRs + poller.removePRs('owner/repo', [2, 3]); + + // Context should still be active + expect(poller.getPollingMetadata('owner/repo').isPolling).toBe(true); + }); + + it('should not duplicate PRs when adding same PR twice', async () => { + mockGithubFetchWithETag.mockResolvedValue({ + data: { + number: 1, + updated_at: new Date().toISOString(), + head: { sha: 'abc123' }, + mergeable: true, + mergeable_state: 'clean' + }, + fromCache: false, + rateLimitInfo: { remaining: 4500, reset: new Date(Date.now() + 3600000), limit: 5000 } + }); + + await poller.startPolling('owner/repo', [1], 'test-token'); + + // Add same PR again (should not duplicate) + poller.addPRs('owner/repo', [1]); + + // No error should occur and polling should continue + expect(poller.getPollingMetadata('owner/repo').isPolling).toBe(true); + }); + }); + + describe('Status Aggregation', () => { + it('should aggregate CI checks status correctly', async () => { + // Mock responses for PR, status, and check-runs endpoints + mockGithubFetchWithETag + // PR endpoint (head.sha passed to fetchChecksStatus, no duplicate fetch) + .mockResolvedValueOnce({ + data: { + number: 1, + updated_at: new Date().toISOString(), + head: { sha: 'abc123' }, + mergeable: true, + mergeable_state: 'clean' + }, + fromCache: false, + rateLimitInfo: { remaining: 4500, reset: new Date(Date.now() + 3600000), limit: 5000 } + }) + // Combined status endpoint + .mockResolvedValueOnce({ + data: { + state: 'success', + statuses: [{ state: 'success' }] + }, + fromCache: false, + rateLimitInfo: { remaining: 4498, reset: new Date(Date.now() + 3600000), limit: 5000 } + }) + // Check runs endpoint + .mockResolvedValueOnce({ + data: { + total_count: 2, + check_runs: [ + { status: 'completed', conclusion: 'success' }, + { status: 'completed', conclusion: 'success' } + ] + }, + fromCache: false, + rateLimitInfo: { remaining: 4497, reset: new Date(Date.now() + 3600000), limit: 5000 } + }) + // Reviews endpoint + .mockResolvedValueOnce({ + data: [ + { state: 'APPROVED', user: { login: 'reviewer1' }, submitted_at: new Date().toISOString() } + ], + fromCache: false, + rateLimitInfo: { remaining: 4496, reset: new Date(Date.now() + 3600000), limit: 5000 } + }); + + await poller.startPolling('owner/repo', [1], 'test-token'); + + // Verify multiple endpoints were called + expect(mockGithubFetchWithETag).toHaveBeenCalled(); + }); + + it('should detect failure in CI checks', async () => { + mockGithubFetchWithETag + .mockResolvedValueOnce({ + data: { + number: 1, + updated_at: new Date().toISOString(), + head: { sha: 'abc123' }, + mergeable: true, + mergeable_state: 'clean' + }, + fromCache: false, + rateLimitInfo: { remaining: 4500, reset: new Date(Date.now() + 3600000), limit: 5000 } + }) + .mockResolvedValueOnce({ + data: { + state: 'failure', + statuses: [{ state: 'failure' }] + }, + fromCache: false, + rateLimitInfo: { remaining: 4498, reset: new Date(Date.now() + 3600000), limit: 5000 } + }) + .mockResolvedValueOnce({ + data: { + total_count: 1, + check_runs: [ + { status: 'completed', conclusion: 'failure' } + ] + }, + fromCache: false, + rateLimitInfo: { remaining: 4497, reset: new Date(Date.now() + 3600000), limit: 5000 } + }) + .mockResolvedValueOnce({ + data: [], + fromCache: false, + rateLimitInfo: { remaining: 4496, reset: new Date(Date.now() + 3600000), limit: 5000 } + }); + + await poller.startPolling('owner/repo', [1], 'test-token'); + + // Verify polling metadata + const metadata = poller.getPollingMetadata('owner/repo'); + expect(metadata.isPolling).toBe(true); + }); + }); + + describe('Main Window Integration', () => { + it('should send status updates to renderer when main window is set', async () => { + const mockMainWindow = { + webContents: { + send: vi.fn() + } + }; + + // Set up main window getter + poller.setMainWindowGetter(() => mockMainWindow as unknown as Electron.BrowserWindow); + + mockGithubFetchWithETag.mockResolvedValue({ + data: { + number: 1, + updated_at: new Date().toISOString(), + head: { sha: 'abc123' }, + mergeable: true, + mergeable_state: 'clean' + }, + fromCache: false, + rateLimitInfo: { remaining: 4500, reset: new Date(Date.now() + 3600000), limit: 5000 } + }); + + await poller.startPolling('owner/repo', [1], 'test-token'); + + // Verify safeSendToRenderer was called + expect(mockSafeSendToRenderer).toHaveBeenCalled(); + }); + + it('should not throw when main window getter is not set', async () => { + mockGithubFetchWithETag.mockResolvedValue({ + data: { + number: 1, + updated_at: new Date().toISOString(), + head: { sha: 'abc123' }, + mergeable: true, + mergeable_state: 'clean' + }, + fromCache: false, + rateLimitInfo: { remaining: 4500, reset: new Date(Date.now() + 3600000), limit: 5000 } + }); + + // Should not throw even without main window + await expect(poller.startPolling('owner/repo', [1], 'test-token')).resolves.not.toThrow(); + }); + }); + + describe('Error Handling', () => { + it('should record errors in metadata', async () => { + mockGithubFetchWithETag.mockRejectedValue(new Error('Network error')); + + await poller.startPolling('owner/repo', [1], 'test-token'); + + const metadata = poller.getPollingMetadata('owner/repo'); + expect(metadata.lastError).toBe('Network error'); + }); + + it('should pause on 403 rate limit error', async () => { + mockGithubFetchWithETag.mockRejectedValue(new Error('403 rate limit exceeded')); + + await poller.startPolling('owner/repo', [1], 'test-token'); + + expect(poller.isPaused()).toBe(true); + }); + + it('should clear errors when stopping polling', async () => { + mockGithubFetchWithETag.mockRejectedValue(new Error('Some error')); + + await poller.startPolling('owner/repo', [1], 'test-token'); + + // Verify error was recorded + expect(poller.getPollingMetadata('owner/repo').lastError).toBeTruthy(); + + // Stop polling + poller.stopPolling('owner/repo'); + + // Error should be cleared + const metadata = poller.getPollingMetadata('owner/repo'); + expect(metadata.lastError).toBeNull(); + }); + }); + + describe('Mergeable State Handling', () => { + it('should schedule retry when mergeable state is unknown', async () => { + // This test verifies that when GitHub returns null for mergeable (still computing), + // the poller schedules a retry after MERGEABLE_RETRY interval + mockGithubFetchWithETag.mockResolvedValue({ + data: { + number: 1, + updated_at: new Date().toISOString(), + head: { sha: 'abc123' }, + mergeable: null, // GitHub still computing + mergeable_state: 'unknown' + }, + fromCache: false, + rateLimitInfo: { remaining: 4500, reset: new Date(Date.now() + 3600000), limit: 5000 } + }); + + await poller.startPolling('owner/repo', [1], 'test-token'); + + // Verify initial poll happened + expect(mockGithubFetchWithETag).toHaveBeenCalled(); + + // Stop polling to prevent infinite timer loop in test + poller.stopPolling('owner/repo'); + + // Verify polling was set up + expect(poller.getPollingMetadata('owner/repo').isPolling).toBe(false); + }); + + it('should verify MERGEABLE_RETRY interval is 2 seconds', () => { + expect(POLLING_INTERVALS.MERGEABLE_RETRY).toBe(2_000); + }); + + it('should handle clean mergeable state without retry', async () => { + mockGithubFetchWithETag.mockResolvedValue({ + data: { + number: 1, + updated_at: new Date().toISOString(), + head: { sha: 'abc123' }, + mergeable: true, + mergeable_state: 'clean' + }, + fromCache: false, + rateLimitInfo: { remaining: 4500, reset: new Date(Date.now() + 3600000), limit: 5000 } + }); + + await poller.startPolling('owner/repo', [1], 'test-token'); + + const initialCallCount = mockGithubFetchWithETag.mock.calls.length; + + // Stop polling immediately to check state + poller.stopPolling('owner/repo'); + + // Verify polling was established + expect(initialCallCount).toBeGreaterThan(0); + }); + }); +}); diff --git a/apps/frontend/src/main/services/pr-status-poller.ts b/apps/frontend/src/main/services/pr-status-poller.ts new file mode 100644 index 00000000..74a66db7 --- /dev/null +++ b/apps/frontend/src/main/services/pr-status-poller.ts @@ -0,0 +1,868 @@ +/** + * PR Status Poller Service + * + * Main process service for polling GitHub PR status updates at intelligent intervals. + * Runs in the Electron main process to avoid renderer throttling when backgrounded. + * + * Features: + * - Multi-tier polling intervals (60s for active PRs, 5min for stable) + * - ETag-based conditional requests to minimize API usage + * - Rate limit monitoring with automatic pause/resume + * - PR classification based on recent activity + * + * @module pr-status-poller + */ + +import type { BrowserWindow } from 'electron'; +import { IPC_CHANNELS } from '../../shared/constants'; +import type { + PRStatus, + PollingMetadata, + PRStatusUpdate, + ChecksStatus, + ReviewsStatus, + MergeableState, + PRPollingTier, + GitHubRateLimitInfo, +} from '../../shared/types/pr-status'; +import { + POLLING_INTERVALS, + RATE_LIMIT_THRESHOLDS, + ACTIVITY_THRESHOLD_MS, +} from '../../shared/types/pr-status'; +import { + githubFetchWithETag, + clearETagCacheForProject, +} from '../ipc-handlers/github/utils'; +import { safeSendToRenderer } from '../ipc-handlers/utils'; + +/** + * PR data from GitHub API (minimal fields needed for status polling) + */ +interface PRData { + number: number; + updated_at: string; + head: { sha: string }; + mergeable_state?: string; + mergeable?: boolean | null; +} + +/** + * Combined status response from GitHub API + */ +interface CombinedStatusResponse { + state: 'success' | 'pending' | 'failure' | 'error'; + statuses: Array<{ state: string }>; +} + +/** + * Check runs response from GitHub API + */ +interface CheckRunsResponse { + total_count: number; + check_runs: Array<{ + status: 'queued' | 'in_progress' | 'completed'; + conclusion: string | null; + }>; +} + +/** + * Reviews response from GitHub API + */ +interface ReviewsResponse { + state: 'APPROVED' | 'CHANGES_REQUESTED' | 'COMMENTED' | 'PENDING' | 'DISMISSED'; + user: { login: string }; + submitted_at: string; +} + +/** + * Internal PR polling state + */ +interface PRPollingState { + prNumber: number; + tier: PRPollingTier; + lastActivity: Date; + lastPolled: Date | null; + checksStatus: ChecksStatus; + reviewsStatus: ReviewsStatus; + mergeableState: MergeableState; + /** Pending retry for unknown mergeable state */ + mergeableRetryTimeout?: NodeJS.Timeout; +} + +/** + * Project polling context + */ +interface ProjectPollingContext { + projectId: string; + owner: string; + repo: string; + token: string; + prStates: Map; + /** Timer for active tier polling */ + activeTimer: NodeJS.Timeout | null; + /** Timer for stable tier polling */ + stableTimer: NodeJS.Timeout | null; + /** Timer for full refresh */ + fullRefreshTimer: NodeJS.Timeout | null; + /** Timestamp of last completed poll cycle */ + lastPollCycle: Date | null; +} + +/** + * PRStatusPoller - Main process service for intelligent PR status polling + * + * Singleton service that manages PR status polling across all projects. + * Runs timers in the main process to avoid Electron's background throttling. + */ +export class PRStatusPoller { + private static instance: PRStatusPoller | null = null; + + /** Active polling contexts by project ID */ + private contexts: Map = new Map(); + + /** Rate limit state */ + private rateLimitInfo: GitHubRateLimitInfo | null = null; + private isPausedForRateLimit = false; + private rateLimitResumeTimeout: NodeJS.Timeout | null = null; + private staggeredResumeTimeouts: NodeJS.Timeout[] = []; + + /** Main window getter for sending updates */ + private getMainWindow: (() => BrowserWindow | null) | null = null; + + /** Last error for each project */ + private lastErrors: Map = new Map(); + + /** Consecutive error count per PR (projectId:prNumber → count) for log suppression */ + private consecutiveErrors: Map = new Map(); + + private constructor() { + // Private constructor for singleton pattern + } + + /** + * Get the singleton instance + */ + static getInstance(): PRStatusPoller { + if (!PRStatusPoller.instance) { + PRStatusPoller.instance = new PRStatusPoller(); + } + return PRStatusPoller.instance; + } + + /** + * Reset the singleton instance (for testing) + */ + static resetInstance(): void { + if (PRStatusPoller.instance) { + PRStatusPoller.instance.stopAllPolling(); + PRStatusPoller.instance = null; + } + } + + /** + * Set the main window getter for sending IPC updates to renderer + */ + setMainWindowGetter(getter: () => BrowserWindow | null): void { + this.getMainWindow = getter; + } + + /** + * Start polling for a project's PRs + * + * @param projectId - Project identifier (owner/repo format) + * @param prNumbers - PR numbers to poll + * @param token - GitHub API token + */ + async startPolling( + projectId: string, + prNumbers: number[], + token: string + ): Promise { + // Stop existing polling for this project + this.stopPolling(projectId); + + // Parse owner/repo from projectId + const [owner, repo] = projectId.split('/'); + if (!owner || !repo) { + console.error(`[PRStatusPoller] Invalid project ID format: ${projectId}`); + return; + } + + // Initialize polling context + const context: ProjectPollingContext = { + projectId, + owner, + repo, + token, + prStates: new Map(), + activeTimer: null, + stableTimer: null, + fullRefreshTimer: null, + lastPollCycle: null, + }; + + // Initialize PR states + for (const prNumber of prNumbers) { + context.prStates.set(prNumber, { + prNumber, + tier: 'stable', // Will be updated on first poll + lastActivity: new Date(), + lastPolled: null, + checksStatus: 'none', + reviewsStatus: 'none', + mergeableState: 'unknown', + }); + } + + this.contexts.set(projectId, context); + + console.log( + `[PRStatusPoller] Started polling for ${projectId} with ${prNumbers.length} PRs` + ); + + // Run initial poll immediately + await this.pollAllPRs(context); + + // Start polling timers + this.startPollingTimers(context); + } + + /** + * Stop polling for a project + */ + stopPolling(projectId: string): void { + const context = this.contexts.get(projectId); + if (!context) { + return; + } + + // Clear all timers + if (context.activeTimer) { + clearInterval(context.activeTimer); + } + if (context.stableTimer) { + clearInterval(context.stableTimer); + } + if (context.fullRefreshTimer) { + clearInterval(context.fullRefreshTimer); + } + + // Clear mergeable retry timeouts + for (const state of context.prStates.values()) { + if (state.mergeableRetryTimeout) { + clearTimeout(state.mergeableRetryTimeout); + } + } + + // Clear ETag cache for this project's endpoints only + clearETagCacheForProject(projectId); + + this.contexts.delete(projectId); + this.lastErrors.delete(projectId); + + console.log(`[PRStatusPoller] Stopped polling for ${projectId}`); + } + + /** + * Stop all polling (cleanup) + */ + stopAllPolling(): void { + for (const projectId of this.contexts.keys()) { + this.stopPolling(projectId); + } + + // Clear rate limit resume timeout + if (this.rateLimitResumeTimeout) { + clearTimeout(this.rateLimitResumeTimeout); + this.rateLimitResumeTimeout = null; + } + this.clearStaggeredResumeTimeouts(); + + this.isPausedForRateLimit = false; + this.rateLimitInfo = null; + } + + /** + * Clear any pending staggered resume timeouts + */ + private clearStaggeredResumeTimeouts(): void { + for (const timeout of this.staggeredResumeTimeouts) { + clearTimeout(timeout); + } + this.staggeredResumeTimeouts = []; + } + + /** + * Add PRs to an existing polling context + */ + addPRs(projectId: string, prNumbers: number[]): void { + const context = this.contexts.get(projectId); + if (!context) { + console.warn( + `[PRStatusPoller] No polling context for ${projectId}, cannot add PRs` + ); + return; + } + + for (const prNumber of prNumbers) { + if (!context.prStates.has(prNumber)) { + context.prStates.set(prNumber, { + prNumber, + tier: 'stable', + lastActivity: new Date(), + lastPolled: null, + checksStatus: 'none', + reviewsStatus: 'none', + mergeableState: 'unknown', + }); + } + } + + console.log( + `[PRStatusPoller] Added ${prNumbers.length} PRs to ${projectId}` + ); + } + + /** + * Remove PRs from an existing polling context + */ + removePRs(projectId: string, prNumbers: number[]): void { + const context = this.contexts.get(projectId); + if (!context) { + return; + } + + for (const prNumber of prNumbers) { + const state = context.prStates.get(prNumber); + if (state?.mergeableRetryTimeout) { + clearTimeout(state.mergeableRetryTimeout); + } + context.prStates.delete(prNumber); + } + + console.log( + `[PRStatusPoller] Removed ${prNumbers.length} PRs from ${projectId}` + ); + } + + /** + * Get current polling metadata for a project + */ + getPollingMetadata(projectId: string): PollingMetadata { + const context = this.contexts.get(projectId); + const lastError = this.lastErrors.get(projectId) ?? null; + + return { + isPolling: context !== undefined, + lastPollCycle: context?.lastPollCycle + ? context.lastPollCycle.toISOString() + : null, + rateLimitRemaining: this.rateLimitInfo?.remaining ?? null, + rateLimitReset: this.rateLimitInfo + ? new Date(this.rateLimitInfo.reset * 1000).toISOString() + : null, + isPausedForRateLimit: this.isPausedForRateLimit, + lastError, + }; + } + + /** + * Check if polling is paused due to rate limit + */ + isPaused(): boolean { + return this.isPausedForRateLimit; + } + + /** + * Start polling timers for a context + */ + private startPollingTimers(context: ProjectPollingContext): void { + // Active tier polling (60s) + context.activeTimer = setInterval(() => { + if (!this.isPausedForRateLimit) { + this.pollPRsByTier(context, 'active'); + } + }, POLLING_INTERVALS.ACTIVE); + + // Stable tier polling (5min) + context.stableTimer = setInterval(() => { + if (!this.isPausedForRateLimit) { + this.pollPRsByTier(context, 'stable'); + } + }, POLLING_INTERVALS.STABLE); + + // Full refresh (15min) + context.fullRefreshTimer = setInterval(() => { + if (!this.isPausedForRateLimit) { + this.pollAllPRs(context); + } + }, POLLING_INTERVALS.FULL_REFRESH); + } + + /** + * Poll all PRs in a context + */ + private async pollAllPRs(context: ProjectPollingContext): Promise { + const prNumbers = Array.from(context.prStates.keys()); + await this.pollPRs(context, prNumbers); + } + + /** + * Poll PRs of a specific tier + */ + private async pollPRsByTier( + context: ProjectPollingContext, + tier: PRPollingTier + ): Promise { + const prNumbers: number[] = []; + for (const [prNumber, state] of context.prStates) { + if (state.tier === tier) { + prNumbers.push(prNumber); + } + } + + if (prNumbers.length > 0) { + await this.pollPRs(context, prNumbers); + } + } + + /** + * Poll specific PRs and update their status + */ + private async pollPRs( + context: ProjectPollingContext, + prNumbers: number[] + ): Promise { + if (this.isPausedForRateLimit) { + return; + } + + const updatedStatuses: PRStatus[] = []; + + // Poll PRs in batches with limited concurrency to avoid long sequential delays + const CONCURRENCY_LIMIT = 5; + for (let i = 0; i < prNumbers.length; i += CONCURRENCY_LIMIT) { + if (this.isPausedForRateLimit) { + break; + } + + const batch = prNumbers.slice(i, i + CONCURRENCY_LIMIT); + const results = await Promise.allSettled( + batch.map((prNumber) => this.fetchPRStatus(context, prNumber)) + ); + + for (let j = 0; j < results.length; j++) { + const result = results[j]; + const prKey = `${context.projectId}:${batch[j]}`; + if (result.status === 'fulfilled' && result.value) { + updatedStatuses.push(result.value); + this.consecutiveErrors.delete(prKey); + } else if (result.status === 'rejected') { + const message = result.reason instanceof Error ? result.reason.message : 'Unknown error'; + const errorCount = (this.consecutiveErrors.get(prKey) ?? 0) + 1; + this.consecutiveErrors.set(prKey, errorCount); + // Only log first error and then every 10th to avoid spam + if (errorCount === 1 || errorCount % 10 === 0) { + console.error( + `[PRStatusPoller] Error polling PR #${batch[j]} (x${errorCount}): ${message}` + ); + } + this.lastErrors.set(context.projectId, message); + } + } + } + + // Track when this poll cycle completed + context.lastPollCycle = new Date(); + + // Send update to renderer + if (updatedStatuses.length > 0) { + this.sendStatusUpdate(context.projectId, updatedStatuses); + } + } + + /** + * Fetch status for a single PR + */ + private async fetchPRStatus( + context: ProjectPollingContext, + prNumber: number + ): Promise { + const state = context.prStates.get(prNumber); + if (!state) { + return null; + } + + const { owner, repo, token } = context; + + try { + // Fetch PR data (for updated_at and mergeable state) + const prEndpoint = `/repos/${owner}/${repo}/pulls/${prNumber}`; + const prResult = await githubFetchWithETag(token, prEndpoint); + this.updateGitHubRateLimitInfo(prResult.rateLimitInfo); + + const prData = prResult.data as PRData; + + // Update last activity and classify tier + const lastActivity = new Date(prData.updated_at); + const tier = this.classifyPR(lastActivity); + state.lastActivity = lastActivity; + state.tier = tier; + + // Fetch CI status (pass headSha to avoid duplicate PR fetch) + const checksStatus = await this.fetchChecksStatus(context, prNumber, prData.head.sha); + + // Fetch review status + const reviewsStatus = await this.fetchReviewsStatus(context, prNumber); + + // Determine mergeable state + const mergeableState = this.determineMergeableState( + prData, + context, + state + ); + + // Update state + state.checksStatus = checksStatus; + state.reviewsStatus = reviewsStatus; + state.mergeableState = mergeableState; + state.lastPolled = new Date(); + + return { + prNumber, + checksStatus, + reviewsStatus, + mergeableState, + lastPolled: state.lastPolled.toISOString(), + pollingTier: tier, + lastActivity: lastActivity.toISOString(), + }; + } catch (error) { + // Pause polling on 403 unless we know rate limit remaining is healthy + // (a permission-denied 403 would still show healthy remaining from prior requests) + if ( + error instanceof Error && + error.message.includes('403') && + (!this.rateLimitInfo || this.rateLimitInfo.remaining < RATE_LIMIT_THRESHOLDS.PAUSE_THRESHOLD) + ) { + this.pauseForRateLimit(); + } + throw error; + } + } + + /** + * Fetch CI checks status (combined status + check runs) + */ + private async fetchChecksStatus( + context: ProjectPollingContext, + prNumber: number, + headSha: string + ): Promise { + const { owner, repo, token } = context; + + try { + // Fetch combined status + const statusEndpoint = `/repos/${owner}/${repo}/commits/${headSha}/status`; + const statusResult = await githubFetchWithETag(token, statusEndpoint); + this.updateGitHubRateLimitInfo(statusResult.rateLimitInfo); + + const statusData = statusResult.data as CombinedStatusResponse; + + // Fetch check runs + const checksEndpoint = `/repos/${owner}/${repo}/commits/${headSha}/check-runs`; + const checksResult = await githubFetchWithETag(token, checksEndpoint); + this.updateGitHubRateLimitInfo(checksResult.rateLimitInfo); + + const checksData = checksResult.data as CheckRunsResponse; + + // Aggregate status + return this.aggregateChecksStatus(statusData, checksData); + } catch (error) { + console.error( + `[PRStatusPoller] Error fetching checks status for PR #${prNumber}:`, + error + ); + return 'none'; + } + } + + /** + * Aggregate checks status from combined status and check runs + */ + private aggregateChecksStatus( + statusData: CombinedStatusResponse, + checksData: CheckRunsResponse + ): ChecksStatus { + const hasStatuses = statusData.statuses.length > 0; + const hasCheckRuns = checksData.total_count > 0; + + if (!hasStatuses && !hasCheckRuns) { + return 'none'; + } + + // Check for failures + const statusFailed = statusData.statuses.some( + (s) => s.state === 'failure' || s.state === 'error' + ); + const checksFailed = checksData.check_runs.some( + (c) => c.status === 'completed' && c.conclusion === 'failure' + ); + + if (statusFailed || checksFailed) { + return 'failure'; + } + + // Check for pending + const statusPending = statusData.statuses.some((s) => s.state === 'pending'); + const checksPending = checksData.check_runs.some( + (c) => c.status === 'queued' || c.status === 'in_progress' + ); + + if (statusPending || checksPending) { + return 'pending'; + } + + // All passed + return 'success'; + } + + /** + * Fetch review status + */ + private async fetchReviewsStatus( + context: ProjectPollingContext, + prNumber: number + ): Promise { + const { owner, repo, token } = context; + + try { + const endpoint = `/repos/${owner}/${repo}/pulls/${prNumber}/reviews`; + const result = await githubFetchWithETag(token, endpoint); + this.updateGitHubRateLimitInfo(result.rateLimitInfo); + + const reviews = result.data as ReviewsResponse[]; + + return this.aggregateReviewsStatus(reviews); + } catch (error) { + console.error( + `[PRStatusPoller] Error fetching reviews for PR #${prNumber}:`, + error + ); + return 'none'; + } + } + + /** + * Aggregate review status from all reviews + * Uses latest review per user to determine final status + */ + private aggregateReviewsStatus(reviews: ReviewsResponse[]): ReviewsStatus { + if (reviews.length === 0) { + return 'none'; + } + + // Sort by submitted_at ascending so later entries (newer) overwrite earlier ones + const sorted = [...reviews].sort( + (a, b) => new Date(a.submitted_at).getTime() - new Date(b.submitted_at).getTime() + ); + + // Get latest review per user + const latestByUser = new Map(); + for (const review of sorted) { + latestByUser.set(review.user.login, review); + } + + const latestReviews = Array.from(latestByUser.values()); + + // Check for changes requested (takes priority) + const hasChangesRequested = latestReviews.some( + (r) => r.state === 'CHANGES_REQUESTED' + ); + if (hasChangesRequested) { + return 'changes_requested'; + } + + // Check for approvals + const hasApproval = latestReviews.some((r) => r.state === 'APPROVED'); + if (hasApproval) { + return 'approved'; + } + + // Check for pending reviews (APPROVED and CHANGES_REQUESTED already returned above) + const hasPendingReview = latestReviews.some((r) => r.state === 'PENDING'); + if (hasPendingReview) { + return 'pending'; + } + + // Only comments/dismissed + return 'none'; + } + + /** + * Determine mergeable state from PR data + */ + private determineMergeableState( + prData: PRData, + context: ProjectPollingContext, + state: PRPollingState + ): MergeableState { + // Clear any pending retry + if (state.mergeableRetryTimeout) { + clearTimeout(state.mergeableRetryTimeout); + state.mergeableRetryTimeout = undefined; + } + + // GitHub returns null when still computing + if (prData.mergeable === null) { + // Schedule retry after 2s + state.mergeableRetryTimeout = setTimeout(() => { + this.pollPRs(context, [prData.number]); + }, POLLING_INTERVALS.MERGEABLE_RETRY); + + return 'unknown'; + } + + // Map GitHub's mergeable_state to our enum + switch (prData.mergeable_state) { + case 'clean': + return 'clean'; + case 'dirty': + case 'unknown': + return 'dirty'; + case 'blocked': + return 'blocked'; + case 'unstable': + // Has conflicts but might still be mergeable + return prData.mergeable ? 'clean' : 'dirty'; + case 'behind': + // Branch is behind base, but mergeable + return prData.mergeable ? 'clean' : 'dirty'; + default: + return 'unknown'; + } + } + + /** + * Classify PR into polling tier based on activity + */ + private classifyPR(lastActivity: Date): PRPollingTier { + const now = new Date(); + const timeSinceActivity = now.getTime() - lastActivity.getTime(); + + return timeSinceActivity < ACTIVITY_THRESHOLD_MS ? 'active' : 'stable'; + } + + /** + * Update rate limit info and check thresholds + */ + private updateGitHubRateLimitInfo(info: { remaining: number; reset: Date; limit: number } | null): void { + if (!info) { + return; + } + + // Convert Date to Unix timestamp (seconds) + this.rateLimitInfo = { + remaining: info.remaining, + limit: info.limit, + reset: Math.floor(info.reset.getTime() / 1000), + }; + + // Check if we should pause + if (info.remaining < RATE_LIMIT_THRESHOLDS.PAUSE_THRESHOLD) { + this.pauseForRateLimit(); + } + } + + /** + * Pause polling due to rate limit + */ + private pauseForRateLimit(): void { + if (this.isPausedForRateLimit) { + return; + } + + this.isPausedForRateLimit = true; + console.warn( + `[PRStatusPoller] Pausing polling due to rate limit (remaining: ${this.rateLimitInfo?.remaining})` + ); + + // Schedule resume after rate limit reset + if (this.rateLimitInfo) { + const resetTime = this.rateLimitInfo.reset * 1000; + const now = Date.now(); + const delayMs = Math.max(0, resetTime - now) + 1000; // Add 1s buffer + + this.rateLimitResumeTimeout = setTimeout(() => { + this.resumePolling(); + }, delayMs); + + console.log( + `[PRStatusPoller] Will resume polling in ${Math.round(delayMs / 1000)}s` + ); + } + + // Send pause notification to all active contexts + for (const projectId of this.contexts.keys()) { + this.sendStatusUpdate(projectId, []); + } + } + + /** + * Resume polling after rate limit reset. + * Staggers requests across contexts to avoid a burst that re-triggers rate limiting. + */ + private resumePolling(): void { + if (!this.isPausedForRateLimit) { + return; + } + + this.isPausedForRateLimit = false; + this.rateLimitResumeTimeout = null; + + console.log('[PRStatusPoller] Resuming polling after rate limit reset'); + + // Stagger polls across contexts (5s apart) to avoid burst + this.clearStaggeredResumeTimeouts(); + let delay = 0; + for (const context of this.contexts.values()) { + const contextId = context.projectId; + const timeout = setTimeout(() => { + if (!this.isPausedForRateLimit && this.contexts.has(contextId)) { + this.pollAllPRs(context); + } + }, delay); + this.staggeredResumeTimeouts.push(timeout); + delay += 5000; + } + } + + /** + * Send status update to renderer via IPC + */ + private sendStatusUpdate(projectId: string, statuses: PRStatus[]): void { + if (!this.getMainWindow) { + return; + } + + const update: PRStatusUpdate = { + projectId, + statuses, + metadata: this.getPollingMetadata(projectId), + }; + + safeSendToRenderer( + this.getMainWindow, + IPC_CHANNELS.GITHUB_PR_STATUS_UPDATE, + update + ); + } +} + +/** + * Get the global PRStatusPoller instance + */ +export function getPRStatusPoller(): PRStatusPoller { + return PRStatusPoller.getInstance(); +} diff --git a/apps/frontend/src/preload/api/modules/github-api.ts b/apps/frontend/src/preload/api/modules/github-api.ts index c2765835..e3a81116 100644 --- a/apps/frontend/src/preload/api/modules/github-api.ts +++ b/apps/frontend/src/preload/api/modules/github-api.ts @@ -8,7 +8,9 @@ import type { GitHubInvestigationResult, IPCResult, VersionSuggestion, - PaginatedIssuesResult + PaginatedIssuesResult, + PRStatusUpdate, + PollingMetadata } from '../../../shared/types'; import { createIpcListener, invokeIpc, sendIpc, IpcListenerCleanup } from './ipc-utils'; @@ -309,6 +311,20 @@ export interface GitHubAPI { onPRLogsUpdated: ( callback: (projectId: string, data: { prNumber: number; entryCount: number }) => void ) => IpcListenerCleanup; + + // PR status polling operations + /** Start background polling for PR status (CI checks, reviews, mergeability) */ + startStatusPolling: (projectId: string, prNumbers: number[]) => Promise; + /** Stop background polling for a project */ + stopStatusPolling: (projectId: string) => Promise; + /** Get current polling metadata (rate limits, errors, etc.) */ + getPollingMetadata: (projectId: string) => Promise; + + // PR status polling event listener + /** Subscribe to PR status updates from background polling */ + onPRStatusUpdate: ( + callback: (update: PRStatusUpdate) => void + ) => IpcListenerCleanup; } /** @@ -761,5 +777,21 @@ export const createGitHubAPI = (): GitHubAPI => ({ onPRLogsUpdated: ( callback: (projectId: string, data: { prNumber: number; entryCount: number }) => void ): IpcListenerCleanup => - createIpcListener(IPC_CHANNELS.GITHUB_PR_LOGS_UPDATED, callback) + createIpcListener(IPC_CHANNELS.GITHUB_PR_LOGS_UPDATED, callback), + + // PR status polling operations + startStatusPolling: (projectId: string, prNumbers: number[]): Promise => + invokeIpc(IPC_CHANNELS.GITHUB_PR_STATUS_POLL_START, { projectId, prNumbers }), + + stopStatusPolling: (projectId: string): Promise => + invokeIpc(IPC_CHANNELS.GITHUB_PR_STATUS_POLL_STOP, { projectId }), + + getPollingMetadata: (projectId: string): Promise => + invokeIpc(IPC_CHANNELS.GITHUB_PR_STATUS_UPDATE, projectId), + + // PR status polling event listener + onPRStatusUpdate: ( + callback: (update: PRStatusUpdate) => void + ): IpcListenerCleanup => + createIpcListener(IPC_CHANNELS.GITHUB_PR_STATUS_UPDATE, callback) }); diff --git a/apps/frontend/src/renderer/components/github-prs/components/PRList.tsx b/apps/frontend/src/renderer/components/github-prs/components/PRList.tsx index 9e1cc3d3..bb64682c 100644 --- a/apps/frontend/src/renderer/components/github-prs/components/PRList.tsx +++ b/apps/frontend/src/renderer/components/github-prs/components/PRList.tsx @@ -5,7 +5,9 @@ import { Button } from '../../ui/button'; import { cn } from '../../../lib/utils'; import type { PRData, PRReviewProgress, PRReviewResult } from '../hooks/useGitHubPRs'; import type { NewCommitsCheck } from '../../../../preload/api/modules/github-api'; +import type { ChecksStatus, ReviewsStatus, MergeableState } from '../../../../shared/types/pr-status'; import { useTranslation } from 'react-i18next'; +import { CompactStatusIndicator } from './StatusIndicator'; /** * Status Flow Dots Component @@ -161,6 +163,12 @@ interface PRReviewInfo { result: PRReviewResult | null; error: string | null; newCommitsCheck?: NewCommitsCheck | null; + /** CI checks status from polling */ + checksStatus?: ChecksStatus | null; + /** Review status from polling */ + reviewsStatus?: ReviewsStatus | null; + /** Mergeable state from polling */ + mergeableState?: MergeableState | null; } interface PRListProps { @@ -292,7 +300,7 @@ export function PRList({ />

{pr.title}

-
+
{pr.author.login} @@ -306,6 +314,13 @@ export function PRList({ +{pr.additions} -{pr.deletions} + {/* GitHub status indicators (CI, reviews, merge status) */} +
diff --git a/apps/frontend/src/renderer/components/github-prs/components/StatusIndicator.tsx b/apps/frontend/src/renderer/components/github-prs/components/StatusIndicator.tsx new file mode 100644 index 00000000..1af319f2 --- /dev/null +++ b/apps/frontend/src/renderer/components/github-prs/components/StatusIndicator.tsx @@ -0,0 +1,197 @@ +import { CheckCircle2, Circle, XCircle, Loader2, AlertTriangle, GitMerge, Ban, HelpCircle } from 'lucide-react'; +import { Badge } from '../../ui/badge'; +import { cn } from '../../../lib/utils'; +import type { ChecksStatus, ReviewsStatus, MergeableState } from '../../../../shared/types/pr-status'; +import { useTranslation } from 'react-i18next'; + +/** + * CI Status Icon Component + * Displays an icon representing the CI checks status + */ +interface CIStatusIconProps { + status: ChecksStatus; + className?: string; +} + +function CIStatusIcon({ status, className }: CIStatusIconProps) { + const baseClasses = 'h-4 w-4'; + + switch (status) { + case 'success': + return ; + case 'pending': + return ; + case 'failure': + return ; + case 'none': + default: + return ; + } +} + +/** + * Review Status Badge Component + * Displays a badge representing the review status + */ +interface ReviewStatusBadgeProps { + status: ReviewsStatus; + className?: string; +} + +function ReviewStatusBadge({ status, className }: ReviewStatusBadgeProps) { + const { t } = useTranslation('common'); + + switch (status) { + case 'approved': + return ( + + + {t('prStatus.review.approved')} + + ); + case 'changes_requested': + return ( + + + {t('prStatus.review.changesRequested')} + + ); + case 'pending': + return ( + + + {t('prStatus.review.pending')} + + ); + case 'none': + default: + return null; + } +} + +/** + * Merge Readiness Icon Component + * Displays an icon representing the merge readiness state + */ +interface MergeReadinessIconProps { + state: MergeableState; + className?: string; +} + +function MergeReadinessIcon({ state, className }: MergeReadinessIconProps) { + const baseClasses = 'h-4 w-4'; + + switch (state) { + case 'clean': + return ; + case 'dirty': + return ; + case 'blocked': + return ; + case 'unknown': + default: + return ; + } +} + +/** + * StatusIndicator Props + */ +export interface StatusIndicatorProps { + /** CI checks status */ + checksStatus?: ChecksStatus | null; + /** Review status */ + reviewsStatus?: ReviewsStatus | null; + /** Mergeable state */ + mergeableState?: MergeableState | null; + /** Additional CSS classes */ + className?: string; + /** Whether to show a compact version (icons only) */ + compact?: boolean; + /** Whether to show the merge readiness indicator */ + showMergeStatus?: boolean; +} + +/** + * StatusIndicator Component + * + * Displays CI status (success/pending/failure icons), review status + * (approved/changes_requested/pending badges), and merge readiness + * for GitHub PRs in the PR list view. + * + * Used alongside the existing PRStatusFlow dots component to provide + * real-time PR status from GitHub's API polling. + */ +const mergeKeyMap: Record = { + clean: 'ready', + dirty: 'conflict', + blocked: 'blocked', +}; + +export function StatusIndicator({ + checksStatus, + reviewsStatus, + mergeableState, + className, + compact = false, + showMergeStatus = true, +}: StatusIndicatorProps) { + const { t } = useTranslation('common'); + + // Don't render if no status data is available + if (!checksStatus && !reviewsStatus && !mergeableState) { + return null; + } + + const mergeKey = mergeableState ? mergeKeyMap[mergeableState] : null; + + return ( +
+ {/* CI Status */} + {checksStatus && checksStatus !== 'none' && ( +
+ + {!compact && ( + + {t(`prStatus.ci.${checksStatus}`)} + + )} +
+ )} + + {/* Review Status */} + {reviewsStatus && reviewsStatus !== 'none' && ( + compact ? ( + + ) : ( + + ) + )} + + {/* Merge Readiness */} + {showMergeStatus && mergeKey && ( +
+ + {!compact && ( + + {t(`prStatus.merge.${mergeKey}`)} + + )} +
+ )} +
+ ); +} + +/** + * Compact Status Indicator + * + * A minimal version showing just icons with tooltips. + * Useful for tight spaces in the PR list. + */ +export function CompactStatusIndicator(props: Omit) { + return ; +} + +// Re-export sub-components for flexibility +export { CIStatusIcon, ReviewStatusBadge, MergeReadinessIcon }; diff --git a/apps/frontend/src/renderer/components/github-prs/hooks/useGitHubPRs.ts b/apps/frontend/src/renderer/components/github-prs/hooks/useGitHubPRs.ts index 8317549e..156fa2c6 100644 --- a/apps/frontend/src/renderer/components/github-prs/hooks/useGitHubPRs.ts +++ b/apps/frontend/src/renderer/components/github-prs/hooks/useGitHubPRs.ts @@ -141,6 +141,9 @@ export function useGitHubPRs( previousResult: state.previousResult, error: state.error, newCommitsCheck: state.newCommitsCheck, + checksStatus: state.checksStatus, + reviewsStatus: state.reviewsStatus, + mergeableState: state.mergeableState, }; }, [projectId, prReviews] @@ -271,6 +274,31 @@ export function useGitHubPRs( }; }, []); + // Stable PR numbers reference - only changes when actual PR numbers change + const prNumbersKey = useMemo(() => prs.map((pr) => pr.number).join(','), [prs]); + + // Start/stop PR status polling based on connection state and PRs + useEffect(() => { + // Only start polling when connected and we have PRs to poll + if (!projectId || !isConnected || !prNumbersKey || !isActive) { + return; + } + + const prNumbers = prNumbersKey.split(',').map(Number); + + // Start polling for PR status (CI checks, reviews, mergeability) + window.electronAPI.github.startStatusPolling(projectId, prNumbers).catch((err) => { + console.warn("Failed to start PR status polling:", err); + }); + + // Cleanup: stop polling when unmounting or when conditions change + return () => { + window.electronAPI.github.stopStatusPolling(projectId).catch((err) => { + console.warn("Failed to stop PR status polling:", err); + }); + }; + }, [projectId, isConnected, prNumbersKey, isActive]); + // Register refresh callback to auto-refresh PR list when reviews complete useEffect(() => { if (!projectId) return; diff --git a/apps/frontend/src/renderer/lib/browser-mock.ts b/apps/frontend/src/renderer/lib/browser-mock.ts index 7dbb6142..72343df9 100644 --- a/apps/frontend/src/renderer/lib/browser-mock.ts +++ b/apps/frontend/src/renderer/lib/browser-mock.ts @@ -236,7 +236,12 @@ const browserMockAPI: ElectronAPI = { approveBatches: async () => ({ success: true, batches: [] }), onAnalyzePreviewProgress: () => () => {}, onAnalyzePreviewComplete: () => () => {}, - onAnalyzePreviewError: () => () => {} + onAnalyzePreviewError: () => () => {}, + // PR status polling + startStatusPolling: async () => true, + stopStatusPolling: async () => true, + getPollingMetadata: async () => null, + onPRStatusUpdate: () => () => {} }, // Queue Routing API (rate limit recovery) diff --git a/apps/frontend/src/renderer/stores/github/pr-review-store.ts b/apps/frontend/src/renderer/stores/github/pr-review-store.ts index 5300a986..4840f8b5 100644 --- a/apps/frontend/src/renderer/stores/github/pr-review-store.ts +++ b/apps/frontend/src/renderer/stores/github/pr-review-store.ts @@ -4,6 +4,12 @@ import type { PRReviewResult, NewCommitsCheck } from '../../../preload/api/modules/github-api'; +import type { + ChecksStatus, + ReviewsStatus, + MergeableState, + PRStatusUpdate +} from '../../../shared/types/pr-status'; /** * PR review state for a single PR @@ -21,6 +27,14 @@ interface PRReviewState { error: string | null; /** Cached result of new commits check - updated when detail view checks */ newCommitsCheck: NewCommitsCheck | null; + /** CI checks status from polling */ + checksStatus: ChecksStatus | null; + /** Review status from polling */ + reviewsStatus: ReviewsStatus | null; + /** Mergeable state from polling */ + mergeableState: MergeableState | null; + /** Timestamp of last status poll (ISO 8601 string) */ + lastPolled: string | null; } interface PRReviewStoreState { @@ -36,6 +50,15 @@ interface PRReviewStoreState { setPRReviewError: (projectId: string, prNumber: number, error: string) => void; setNewCommitsCheck: (projectId: string, prNumber: number, check: NewCommitsCheck) => void; clearPRReview: (projectId: string, prNumber: number) => void; + /** Update PR status from polling (CI checks, reviews, mergeability) */ + setPRStatus: (projectId: string, prNumber: number, status: { + checksStatus: ChecksStatus; + reviewsStatus: ReviewsStatus; + mergeableState: MergeableState; + lastPolled: string; + }) => void; + /** Clear PR status fields for a specific PR */ + clearPRStatus: (projectId: string, prNumber: number) => void; // Selectors getPRReviewState: (projectId: string, prNumber: number) => PRReviewState | null; @@ -69,7 +92,11 @@ export const usePRReviewStore = create((set, get) => ({ result: null, previousResult: null, error: null, - newCommitsCheck: existing?.newCommitsCheck ?? null + newCommitsCheck: existing?.newCommitsCheck ?? null, + checksStatus: existing?.checksStatus ?? null, + reviewsStatus: existing?.reviewsStatus ?? null, + mergeableState: existing?.mergeableState ?? null, + lastPolled: existing?.lastPolled ?? null } } }; @@ -99,7 +126,11 @@ export const usePRReviewStore = create((set, get) => ({ result: null, previousResult: existing?.result ?? null, // Preserve for follow-up continuity error: null, - newCommitsCheck: existing?.newCommitsCheck ?? null + newCommitsCheck: existing?.newCommitsCheck ?? null, + checksStatus: existing?.checksStatus ?? null, + reviewsStatus: existing?.reviewsStatus ?? null, + mergeableState: existing?.mergeableState ?? null, + lastPolled: existing?.lastPolled ?? null } } }; @@ -120,7 +151,11 @@ export const usePRReviewStore = create((set, get) => ({ result: existing?.result ?? null, previousResult: existing?.previousResult ?? null, error: null, - newCommitsCheck: existing?.newCommitsCheck ?? null + newCommitsCheck: existing?.newCommitsCheck ?? null, + checksStatus: existing?.checksStatus ?? null, + reviewsStatus: existing?.reviewsStatus ?? null, + mergeableState: existing?.mergeableState ?? null, + lastPolled: existing?.lastPolled ?? null } } }; @@ -143,7 +178,11 @@ export const usePRReviewStore = create((set, get) => ({ error: result.error ?? null, // Clear new commits check when review completes (it was just reviewed) // BUT preserve it during preload/refresh to avoid race condition - newCommitsCheck: options?.preserveNewCommitsCheck ? (existing?.newCommitsCheck ?? null) : null + newCommitsCheck: options?.preserveNewCommitsCheck ? (existing?.newCommitsCheck ?? null) : null, + checksStatus: existing?.checksStatus ?? null, + reviewsStatus: existing?.reviewsStatus ?? null, + mergeableState: existing?.mergeableState ?? null, + lastPolled: existing?.lastPolled ?? null } } }; @@ -164,7 +203,11 @@ export const usePRReviewStore = create((set, get) => ({ result: existing?.result ?? null, previousResult: existing?.previousResult ?? null, error, - newCommitsCheck: existing?.newCommitsCheck ?? null + newCommitsCheck: existing?.newCommitsCheck ?? null, + checksStatus: existing?.checksStatus ?? null, + reviewsStatus: existing?.reviewsStatus ?? null, + mergeableState: existing?.mergeableState ?? null, + lastPolled: existing?.lastPolled ?? null } } }; @@ -187,7 +230,11 @@ export const usePRReviewStore = create((set, get) => ({ result: null, previousResult: null, error: null, - newCommitsCheck: check + newCommitsCheck: check, + checksStatus: null, + reviewsStatus: null, + mergeableState: null, + lastPolled: null } } }; @@ -209,6 +256,71 @@ export const usePRReviewStore = create((set, get) => ({ return { prReviews: rest }; }), + setPRStatus: (projectId: string, prNumber: number, status: { + checksStatus: ChecksStatus; + reviewsStatus: ReviewsStatus; + mergeableState: MergeableState; + lastPolled: string; + }) => set((state) => { + const key = `${projectId}:${prNumber}`; + const existing = state.prReviews[key]; + if (!existing) { + // Create a minimal state if none exists + return { + prReviews: { + ...state.prReviews, + [key]: { + prNumber, + projectId, + isReviewing: false, + startedAt: null, + progress: null, + result: null, + previousResult: null, + error: null, + newCommitsCheck: null, + checksStatus: status.checksStatus, + reviewsStatus: status.reviewsStatus, + mergeableState: status.mergeableState, + lastPolled: status.lastPolled + } + } + }; + } + return { + prReviews: { + ...state.prReviews, + [key]: { + ...existing, + checksStatus: status.checksStatus, + reviewsStatus: status.reviewsStatus, + mergeableState: status.mergeableState, + lastPolled: status.lastPolled + } + } + }; + }), + + clearPRStatus: (projectId: string, prNumber: number) => set((state) => { + const key = `${projectId}:${prNumber}`; + const existing = state.prReviews[key]; + if (!existing) { + return state; + } + return { + prReviews: { + ...state.prReviews, + [key]: { + ...existing, + checksStatus: null, + reviewsStatus: null, + mergeableState: null, + lastPolled: null + } + } + }; + }), + // Selectors getPRReviewState: (projectId: string, prNumber: number) => { const { prReviews } = get(); @@ -298,6 +410,21 @@ export function initializePRReviewListeners(): void { ); cleanupFunctions.push(cleanupAuthChanged); + // Listen for PR status polling updates (CI checks, reviews, mergeability) + window.electronAPI.github.onPRStatusUpdate( + (update: PRStatusUpdate) => { + const { projectId, statuses } = update; + for (const status of statuses) { + store.setPRStatus(projectId, status.prNumber, { + checksStatus: status.checksStatus, + reviewsStatus: status.reviewsStatus, + mergeableState: status.mergeableState, + lastPolled: status.lastPolled ?? new Date().toISOString() + }); + } + } + ); + prReviewListenersInitialized = true; } diff --git a/apps/frontend/src/shared/constants/ipc.ts b/apps/frontend/src/shared/constants/ipc.ts index e85d178a..1488a4f4 100644 --- a/apps/frontend/src/shared/constants/ipc.ts +++ b/apps/frontend/src/shared/constants/ipc.ts @@ -417,6 +417,11 @@ export const IPC_CHANNELS = { // GitHub PR Logs (for viewing AI review logs) GITHUB_PR_GET_LOGS: 'github:pr:getLogs', + // GitHub PR Status Polling (production system checks) + GITHUB_PR_STATUS_POLL_START: 'github:pr:statusPollStart', // Start polling PR status + GITHUB_PR_STATUS_POLL_STOP: 'github:pr:statusPollStop', // Stop polling PR status + GITHUB_PR_STATUS_UPDATE: 'github:pr:statusUpdate', // Event: PR status updated (main -> renderer) + // GitHub PR Memory operations (saves review insights to memory layer) GITHUB_PR_MEMORY_GET: 'github:pr:memory:get', // Get PR review memories GITHUB_PR_MEMORY_SEARCH: 'github:pr:memory:search', // Search PR review memories diff --git a/apps/frontend/src/shared/i18n/locales/en/common.json b/apps/frontend/src/shared/i18n/locales/en/common.json index 52d380b9..f828c513 100644 --- a/apps/frontend/src/shared/i18n/locales/en/common.json +++ b/apps/frontend/src/shared/i18n/locales/en/common.json @@ -693,5 +693,31 @@ "progress": "Progress", "lastActivityPrefix": "last activity", "lastProgressUpdateTooltip": "Last progress update received" + }, + "prStatus": { + "ci": { + "success": "CI Passed", + "pending": "CI Pending", + "failure": "CI Failed", + "successTooltip": "All CI checks have passed", + "pendingTooltip": "CI checks are still running", + "failureTooltip": "One or more CI checks have failed" + }, + "review": { + "approved": "Approved", + "changesRequested": "Changes Requested", + "pending": "Review Pending", + "approvedTooltip": "This PR has been approved", + "changesRequestedTooltip": "Changes have been requested on this PR", + "pendingTooltip": "Waiting for review" + }, + "merge": { + "ready": "Ready to Merge", + "blocked": "Merge Blocked", + "conflict": "Has Conflicts", + "readyTooltip": "This PR is ready to be merged", + "blockedTooltip": "This PR cannot be merged due to blocking conditions", + "conflictTooltip": "This PR has merge conflicts that need to be resolved" + } } } diff --git a/apps/frontend/src/shared/i18n/locales/fr/common.json b/apps/frontend/src/shared/i18n/locales/fr/common.json index ca99cc40..40a2ef2f 100644 --- a/apps/frontend/src/shared/i18n/locales/fr/common.json +++ b/apps/frontend/src/shared/i18n/locales/fr/common.json @@ -693,5 +693,31 @@ "progress": "Progression", "lastActivityPrefix": "dernière activité", "lastProgressUpdateTooltip": "Dernière mise à jour de progression reçue" + }, + "prStatus": { + "ci": { + "success": "CI réussie", + "pending": "CI en attente", + "failure": "CI échouée", + "successTooltip": "Toutes les vérifications CI ont réussi", + "pendingTooltip": "Les vérifications CI sont en cours", + "failureTooltip": "Une ou plusieurs vérifications CI ont échoué" + }, + "review": { + "approved": "Approuvée", + "changesRequested": "Modifications demandées", + "pending": "Révision en attente", + "approvedTooltip": "Cette PR a été approuvée", + "changesRequestedTooltip": "Des modifications ont été demandées sur cette PR", + "pendingTooltip": "En attente de révision" + }, + "merge": { + "ready": "Prête à fusionner", + "blocked": "Fusion bloquée", + "conflict": "Conflits détectés", + "readyTooltip": "Cette PR est prête à être fusionnée", + "blockedTooltip": "Cette PR ne peut pas être fusionnée en raison de conditions bloquantes", + "conflictTooltip": "Cette PR a des conflits de fusion qui doivent être résolus" + } } } diff --git a/apps/frontend/src/shared/types/index.ts b/apps/frontend/src/shared/types/index.ts index bb45441c..b42b614f 100644 --- a/apps/frontend/src/shared/types/index.ts +++ b/apps/frontend/src/shared/types/index.ts @@ -19,6 +19,7 @@ export * from './roadmap'; export * from './integrations'; export * from './app-update'; export * from './cli'; +export * from './pr-status'; // IPC types (must be last to use types from other modules) export * from './ipc'; diff --git a/apps/frontend/src/shared/types/pr-status.ts b/apps/frontend/src/shared/types/pr-status.ts new file mode 100644 index 00000000..46a0170e --- /dev/null +++ b/apps/frontend/src/shared/types/pr-status.ts @@ -0,0 +1,179 @@ +/** + * PR Status Polling Types + * + * Types for the smart PR status polling system that automatically fetches + * and displays CI checks, review status, and mergeability for GitHub PRs. + * Used across IPC boundary (main process, preload, renderer). + */ + +/** + * CI checks status - combined from commit status + check runs + * - success: All checks passed + * - pending: At least one check pending, none failed + * - failure: At least one check failed + * - none: No status checks configured + */ +export type ChecksStatus = 'success' | 'pending' | 'failure' | 'none'; + +/** + * Review status - aggregated from all reviewers + * - approved: At least one approval, no changes requested + * - changes_requested: Any reviewer requested changes + * - pending: No reviews or only comments + * - none: No reviews + */ +export type ReviewsStatus = 'approved' | 'changes_requested' | 'pending' | 'none'; + +/** + * Mergeable state + * - clean: Ready to merge + * - dirty: Has conflicts + * - blocked: Branch protection blocks merge + * - unknown: GitHub still computing (retry after 2s) + */ +export type MergeableState = 'clean' | 'dirty' | 'blocked' | 'unknown'; + +/** + * PR classification for polling interval tiers + * - active: Updated within last 30 minutes (poll every 60s) + * - stable: No updates for 30+ minutes (poll every 5min) + */ +export type PRPollingTier = 'active' | 'stable'; + +/** + * PR status data - represents the current status of a single PR + */ +export interface PRStatus { + /** PR number */ + prNumber: number; + /** CI checks status */ + checksStatus: ChecksStatus; + /** Review status */ + reviewsStatus: ReviewsStatus; + /** Mergeable state */ + mergeableState: MergeableState; + /** ISO timestamp of last status poll */ + lastPolled: string | null; + /** Polling tier classification */ + pollingTier: PRPollingTier; + /** ISO timestamp of last PR activity (for tier classification) */ + lastActivity: string | null; +} + +/** + * Polling metadata - tracks polling state and rate limits + */ +export interface PollingMetadata { + /** Whether polling is currently active */ + isPolling: boolean; + /** ISO timestamp of last successful poll cycle */ + lastPollCycle: string | null; + /** GitHub API rate limit remaining */ + rateLimitRemaining: number | null; + /** ISO timestamp when rate limit resets */ + rateLimitReset: string | null; + /** Whether polling is paused due to rate limit */ + isPausedForRateLimit: boolean; + /** Error message if polling failed */ + lastError: string | null; +} + +/** + * ETag cache entry - stores cached response with ETag for conditional requests + */ +export interface ETagCacheEntry { + /** ETag value from response header */ + etag: string; + /** Cached response data */ + data: unknown; + /** ISO timestamp when cached */ + lastUpdated: string; +} + +/** + * ETag cache - stores cached responses keyed by endpoint URL + */ +export type ETagCache = Record; + +/** + * PR status update event - sent from main process to renderer + */ +export interface PRStatusUpdate { + /** Project ID (owner/repo format) */ + projectId: string; + /** Array of updated PR statuses */ + statuses: PRStatus[]; + /** Polling metadata */ + metadata: PollingMetadata; +} + +/** + * Start polling request - sent from renderer to main process + */ +export interface StartPollingRequest { + /** Project ID (owner/repo format) */ + projectId: string; + /** PR numbers to poll */ + prNumbers: number[]; +} + +/** + * Stop polling request - sent from renderer to main process + */ +export interface StopPollingRequest { + /** Project ID (owner/repo format) */ + projectId: string; +} + +/** + * GitHub API rate limit info - extracted from response headers + */ +export interface GitHubRateLimitInfo { + /** Requests remaining in current window */ + remaining: number; + /** Total requests allowed in window */ + limit: number; + /** Unix timestamp (seconds) when window resets */ + reset: number; +} + +/** + * GitHub fetch result with ETag support + */ +export interface GitHubFetchResult { + /** Response data (null if 304 Not Modified) */ + data: T | null; + /** Whether response came from cache (304 Not Modified) */ + fromCache: boolean; + /** New ETag from response (if provided) */ + etag: string | null; + /** Rate limit info from response headers */ + rateLimit: GitHubRateLimitInfo | null; +} + +/** + * Polling intervals in milliseconds + */ +export const POLLING_INTERVALS = { + /** 60 seconds for recently active PRs */ + ACTIVE: 60_000, + /** 5 minutes for stable PRs */ + STABLE: 300_000, + /** 15 minutes for full refresh of all PRs */ + FULL_REFRESH: 900_000, + /** 2 seconds retry when mergeable state is unknown */ + MERGEABLE_RETRY: 2_000, +} as const; + +/** + * Rate limit thresholds + */ +export const RATE_LIMIT_THRESHOLDS = { + /** Pause polling when remaining requests drop below this */ + PAUSE_THRESHOLD: 100, +} as const; + +/** + * Activity threshold for PR classification (30 minutes in milliseconds) + */ +export const ACTIVITY_THRESHOLD_MS = 30 * 60 * 1000;