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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Andy
2026-02-09 10:36:56 +01:00
committed by GitHub
parent bb7e189374
commit 48d5f7a321
17 changed files with 3221 additions and 12 deletions
@@ -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';
@@ -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<PollingMetadata> => {
debugLog("getPollingMetadata handler called", { projectId });
return prStatusPoller.getPollingMetadata(projectId);
}
);
debugLog("PR handlers registered");
}
@@ -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<GitHubFetchWithETagResult> {
const url = endpoint.startsWith('http')
? endpoint
: `https://api.github.com${endpoint}`;
const cached = etagCache[url];
const headers: Record<string, string> = {
'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
};
}
@@ -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);
});
});
});
@@ -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);
});
});
});
@@ -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<number, PRPollingState>;
/** 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<string, ProjectPollingContext> = 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<string, string> = new Map();
/** Consecutive error count per PR (projectId:prNumber → count) for log suppression */
private consecutiveErrors: Map<string, number> = 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<void> {
// 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<void> {
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<void> {
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<void> {
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<PRStatus | null> {
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<ChecksStatus> {
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<ReviewsStatus> {
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<string, ReviewsResponse>();
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();
}
@@ -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<boolean>;
/** Stop background polling for a project */
stopStatusPolling: (projectId: string) => Promise<boolean>;
/** Get current polling metadata (rate limits, errors, etc.) */
getPollingMetadata: (projectId: string) => Promise<PollingMetadata | null>;
// 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<boolean> =>
invokeIpc(IPC_CHANNELS.GITHUB_PR_STATUS_POLL_START, { projectId, prNumbers }),
stopStatusPolling: (projectId: string): Promise<boolean> =>
invokeIpc(IPC_CHANNELS.GITHUB_PR_STATUS_POLL_STOP, { projectId }),
getPollingMetadata: (projectId: string): Promise<PollingMetadata | null> =>
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)
});
@@ -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({
/>
</div>
<h3 className="font-medium text-sm truncate">{pr.title}</h3>
<div className="flex items-center gap-3 mt-2 text-xs text-muted-foreground">
<div className="flex items-center gap-3 mt-2 text-xs text-muted-foreground flex-wrap">
<span className="flex items-center gap-1">
<User className="h-3 w-3" />
{pr.author.login}
@@ -306,6 +314,13 @@ export function PRList({
<span className="text-success">+{pr.additions}</span>
<span className="text-destructive">-{pr.deletions}</span>
</span>
{/* GitHub status indicators (CI, reviews, merge status) */}
<CompactStatusIndicator
checksStatus={reviewState?.checksStatus}
reviewsStatus={reviewState?.reviewsStatus}
mergeableState={reviewState?.mergeableState}
showMergeStatus={false}
/>
</div>
</div>
</div>
@@ -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 <CheckCircle2 className={cn(baseClasses, 'text-emerald-400', className)} />;
case 'pending':
return <Loader2 className={cn(baseClasses, 'text-amber-400 animate-spin', className)} />;
case 'failure':
return <XCircle className={cn(baseClasses, 'text-red-400', className)} />;
case 'none':
default:
return <Circle className={cn(baseClasses, 'text-muted-foreground/50', className)} />;
}
}
/**
* 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 (
<Badge variant="success" className={cn('gap-1', className)}>
<CheckCircle2 className="h-3 w-3" />
{t('prStatus.review.approved')}
</Badge>
);
case 'changes_requested':
return (
<Badge variant="destructive" className={cn('gap-1', className)}>
<AlertTriangle className="h-3 w-3" />
{t('prStatus.review.changesRequested')}
</Badge>
);
case 'pending':
return (
<Badge variant="warning" className={cn('gap-1', className)}>
<Circle className="h-3 w-3" />
{t('prStatus.review.pending')}
</Badge>
);
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 <GitMerge className={cn(baseClasses, 'text-emerald-400', className)} />;
case 'dirty':
return <AlertTriangle className={cn(baseClasses, 'text-amber-400', className)} />;
case 'blocked':
return <Ban className={cn(baseClasses, 'text-red-400', className)} />;
case 'unknown':
default:
return <HelpCircle className={cn(baseClasses, 'text-muted-foreground/50', className)} />;
}
}
/**
* 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<string, string> = {
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 (
<div className={cn('flex items-center gap-2', className)}>
{/* CI Status */}
{checksStatus && checksStatus !== 'none' && (
<div className="flex items-center gap-1" title={t(`prStatus.ci.${checksStatus}`)}>
<CIStatusIcon status={checksStatus} />
{!compact && (
<span className="text-xs text-muted-foreground">
{t(`prStatus.ci.${checksStatus}`)}
</span>
)}
</div>
)}
{/* Review Status */}
{reviewsStatus && reviewsStatus !== 'none' && (
compact ? (
<ReviewStatusBadge status={reviewsStatus} className="px-1.5 py-0" />
) : (
<ReviewStatusBadge status={reviewsStatus} />
)
)}
{/* Merge Readiness */}
{showMergeStatus && mergeKey && (
<div className="flex items-center gap-1" title={t(`prStatus.merge.${mergeKey}`)}>
<MergeReadinessIcon state={mergeableState!} />
{!compact && (
<span className="text-xs text-muted-foreground">
{t(`prStatus.merge.${mergeKey}`)}
</span>
)}
</div>
)}
</div>
);
}
/**
* Compact Status Indicator
*
* A minimal version showing just icons with tooltips.
* Useful for tight spaces in the PR list.
*/
export function CompactStatusIndicator(props: Omit<StatusIndicatorProps, 'compact'>) {
return <StatusIndicator {...props} compact />;
}
// Re-export sub-components for flexibility
export { CIStatusIcon, ReviewStatusBadge, MergeReadinessIcon };
@@ -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;
@@ -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)
@@ -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<PRReviewStoreState>((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<PRReviewStoreState>((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<PRReviewStoreState>((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<PRReviewStoreState>((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<PRReviewStoreState>((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<PRReviewStoreState>((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<PRReviewStoreState>((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;
}
@@ -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
@@ -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"
}
}
}
@@ -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"
}
}
}
+1
View File
@@ -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';
+179
View File
@@ -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<string, ETagCacheEntry>;
/**
* 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<T = unknown> {
/** 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;