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:
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user