main.ts
1 import { app, autoUpdater, BrowserWindow, dialog, ipcMain, Notification, safeStorage, shell } from 'electron'; 2 import path from 'path'; 3 import fs from 'fs'; 4 import os from 'os'; 5 import http from 'http'; 6 import crypto from 'crypto'; 7 import { Octokit } from '@octokit/rest'; 8 import { 9 parsePrUrl, 10 getPrMetadata, 11 getPrDiff, 12 getChangedFiles, 13 getFileContent, 14 getNeighborFiles, 15 searchPullRequests, 16 getCiStatus, 17 getReviewStatus, 18 } from '../lib/github'; 19 import type { CiCheck, PrStatus } from '../lib/types'; 20 import { buildContextPackage } from '../lib/context-builder'; 21 import { generateReviewGuide } from '../lib/agent'; 22 import { checkForUpdate } from '../lib/updater'; 23 import { renderDiffHunk, reRenderAllHunks } from '../lib/highlight'; 24 import { parseDiffLines, parsePatchValidLines } from '../lib/diff-lines'; 25 import { setBinaryOverride, detectBinaryPath, resolveBinaryPath } from '../lib/providers/shared'; 26 import { getProvider } from '../lib/provider'; 27 import { buildSlideChatSystemPrompt, buildSlideChatUserMessage } from '../lib/chat-agent'; 28 import { buildIndexedHunks, expandFullDiff, formatHunkIndexForPrompt, sortDiffHunks } from '../lib/diff-parse'; 29 import { classifyFiles, filterDiff, buildExcludedFilesSummary } from '../lib/file-filter'; 30 import { writeMcpConfig, cleanupMcpConfig } from '../lib/mcp-config'; 31 import type { 32 ChangedFile, 33 DiffHunk, 34 GenerateReviewRequest, 35 ModelId, 36 Preferences, 37 ReviewGuide, 38 ReviewHistoryEntry, 39 Slide, 40 SendSlideChatRequest, 41 StartReviewResult, 42 SubmitReviewRequest, 43 FreshnessResult, 44 } from '../lib/types'; 45 46 // Injected by Electron Forge Vite plugin 47 declare const MAIN_WINDOW_VITE_DEV_SERVER_URL: string; 48 declare const MAIN_WINDOW_VITE_NAME: string; 49 50 // Injected by Vite define 51 declare const __GH_CLIENT_SECRET__: string; 52 53 const GITHUB_CLIENT_ID = 'Ov23lifGr1yrXtcZD5Og'; 54 const GITHUB_CLIENT_SECRET: string = typeof __GH_CLIENT_SECRET__ !== 'undefined' ? __GH_CLIENT_SECRET__ : ''; 55 56 // ── In-memory cache ───────────────────────────────────────────── 57 58 let cachedToken: string | null = null; 59 let cachedLogin: string | null = null; 60 61 // ── Token storage helpers ──────────────────────────────────────── 62 63 function getTokenPath() { 64 return path.join(app.getPath('userData'), 'token.enc'); 65 } 66 67 function getPlainTokenPath() { 68 return getTokenPath() + '.plain'; 69 } 70 71 function loadStoredToken(): string | null { 72 // Avoid calling safeStorage.isEncryptionAvailable() — on macOS it can trigger its 73 // own Keychain prompt before decryptString() does, causing two prompts on startup. 74 // Instead, try decryptString() directly and fall back to plaintext on any error. 75 try { 76 if (fs.existsSync(getTokenPath())) { 77 return safeStorage.decryptString(fs.readFileSync(getTokenPath())); 78 } 79 } catch { 80 // Encryption unavailable or data corrupted — fall through to plaintext 81 } 82 try { 83 if (fs.existsSync(getPlainTokenPath())) { 84 return fs.readFileSync(getPlainTokenPath(), 'utf-8').trim(); 85 } 86 } catch { 87 // ignore 88 } 89 return null; 90 } 91 92 function persistToken(token: string) { 93 if (safeStorage.isEncryptionAvailable()) { 94 fs.writeFileSync(getTokenPath(), safeStorage.encryptString(token)); 95 } else { 96 fs.writeFileSync(getPlainTokenPath(), token, { encoding: 'utf-8', mode: 0o600 }); 97 } 98 } 99 100 function deleteStoredToken() { 101 const p = getTokenPath(); 102 if (fs.existsSync(p)) fs.unlinkSync(p); 103 const plain = getPlainTokenPath(); 104 if (fs.existsSync(plain)) fs.unlinkSync(plain); 105 } 106 107 function getResolvedToken(): string | null { 108 if (cachedToken) return cachedToken; 109 const token = loadStoredToken(); 110 if (token) cachedToken = token; // cache so keychain is only unlocked once per session 111 return token; 112 } 113 114 // ── OAuth flow ────────────────────────────────────────────────── 115 116 async function exchangeCodeForToken(code: string, codeVerifier: string, redirectUri: string): Promise<string> { 117 const body = new URLSearchParams({ 118 client_id: GITHUB_CLIENT_ID, 119 client_secret: GITHUB_CLIENT_SECRET, 120 code, 121 code_verifier: codeVerifier, 122 redirect_uri: redirectUri, 123 }); 124 125 const res = await fetch('https://github.com/login/oauth/access_token', { 126 method: 'POST', 127 headers: { Accept: 'application/json', 'Content-Type': 'application/x-www-form-urlencoded' }, 128 body: body.toString(), 129 }); 130 131 const data = (await res.json()) as { access_token?: string; error?: string; error_description?: string }; 132 if (!data.access_token) { 133 throw new Error(data.error_description ?? data.error ?? 'OAuth token exchange failed'); 134 } 135 return data.access_token; 136 } 137 138 async function fetchGitHubLogin(token: string): Promise<string> { 139 const res = await fetch('https://api.github.com/user', { 140 headers: { 141 Authorization: `token ${token}`, 142 'User-Agent': 'Gnosis-App', 143 }, 144 }); 145 const data = (await res.json()) as { login?: string }; 146 return data.login ?? 'unknown'; 147 } 148 149 async function validateAndFetchLogin(token: string): Promise<string> { 150 const res = await fetch('https://api.github.com/user', { 151 headers: { Authorization: `token ${token}`, 'User-Agent': 'Gnosis-App' }, 152 }); 153 if (!res.ok) throw new Error(`Invalid token (GitHub returned ${res.status})`); 154 const data = (await res.json()) as { login?: string }; 155 if (!data.login) throw new Error('Token validated but could not retrieve GitHub username'); 156 return data.login; 157 } 158 159 function generatePkce(): { verifier: string; challenge: string } { 160 const verifier = crypto.randomBytes(32).toString('base64url'); 161 const challenge = crypto.createHash('sha256').update(verifier).digest('base64url'); 162 return { verifier, challenge }; 163 } 164 165 function runOAuthFlow(): Promise<void> { 166 return new Promise((resolve, reject) => { 167 const state = crypto.randomBytes(20).toString('hex'); 168 const { verifier, challenge } = generatePkce(); 169 170 // eslint-disable-next-line @typescript-eslint/no-misused-promises -- async HTTP handler 171 const server = http.createServer(async (req, res) => { 172 const url = new URL(req.url ?? '/', `http://localhost`); 173 if (url.pathname !== '/callback') { 174 res.writeHead(404); 175 res.end(); 176 return; 177 } 178 179 const returnedState = url.searchParams.get('state'); 180 const code = url.searchParams.get('code'); 181 const errorParam = url.searchParams.get('error'); 182 183 if (returnedState !== state) { 184 res.writeHead(400, { 'Content-Type': 'text/html' }); 185 res.end('<html><body><p>Invalid state parameter. Please try again.</p></body></html>'); 186 server.close(); 187 reject(new Error('OAuth state mismatch')); 188 return; 189 } 190 191 if (errorParam || !code) { 192 const desc = url.searchParams.get('error_description') ?? errorParam ?? 'Unknown error'; 193 res.writeHead(400, { 'Content-Type': 'text/html' }); 194 res.end(`<html><body><p>Sign-in failed: ${desc}</p></body></html>`); 195 server.close(); 196 reject(new Error(desc)); 197 return; 198 } 199 200 const addr = server.address(); 201 const port = typeof addr === 'object' && addr ? addr.port : 0; 202 const redirectUri = `http://127.0.0.1:${port}/callback`; 203 204 try { 205 const token = await exchangeCodeForToken(code, verifier, redirectUri); 206 const login = await fetchGitHubLogin(token); 207 persistToken(token); 208 cachedToken = token; 209 cachedLogin = login; 210 211 res.writeHead(200, { 'Content-Type': 'text/html' }); 212 res.end('<html><body><p>You are now signed in to Gnosis. You can close this tab.</p></body></html>'); 213 server.close(); 214 resolve(); 215 } catch (err) { 216 res.writeHead(500, { 'Content-Type': 'text/html' }); 217 res.end('<html><body><p>Authentication failed. Please try again.</p></body></html>'); 218 server.close(); 219 reject(err instanceof Error ? err : new Error(String(err))); 220 } 221 }); 222 223 server.listen(0, '127.0.0.1', () => { 224 const addr = server.address(); 225 if (!addr || typeof addr === 'string') { 226 server.close(); 227 reject(new Error('Failed to start OAuth callback server')); 228 return; 229 } 230 231 const port = addr.port; 232 const redirectUri = `http://127.0.0.1:${port}/callback`; 233 const params = new URLSearchParams({ 234 client_id: GITHUB_CLIENT_ID, 235 redirect_uri: redirectUri, 236 scope: 'repo', 237 state, 238 code_challenge: challenge, 239 code_challenge_method: 'S256', 240 }); 241 void shell.openExternal(`https://github.com/login/oauth/authorize?${params}`); 242 }); 243 244 const timeout = setTimeout( 245 () => { 246 server.close(); 247 reject(new Error('OAuth sign-in timed out after 5 minutes')); 248 }, 249 5 * 60 * 1000 250 ); 251 252 server.on('close', () => clearTimeout(timeout)); 253 }); 254 } 255 256 // ── Persistent logging ─────────────────────────────────────────── 257 258 function getLogsDir() { 259 return path.join(app.getPath('userData'), 'logs'); 260 } 261 262 function setupLogging() { 263 const logsDir = getLogsDir(); 264 if (!fs.existsSync(logsDir)) fs.mkdirSync(logsDir, { recursive: true }); 265 266 const logPath = path.join(logsDir, 'main.log'); 267 const prevPath = path.join(logsDir, 'main.log.1'); 268 269 // Rotate previous log 270 if (fs.existsSync(logPath)) { 271 try { 272 fs.renameSync(logPath, prevPath); 273 } catch { 274 // Best-effort rotation 275 } 276 } 277 278 const stream = fs.createWriteStream(logPath, { flags: 'a' }); 279 const origLog = console.log.bind(console); 280 const origWarn = console.warn.bind(console); 281 const origError = console.error.bind(console); 282 283 function write(level: string, args: unknown[]) { 284 const ts = new Date().toISOString(); 285 const msg = args.map((a) => (typeof a === 'string' ? a : JSON.stringify(a))).join(' '); 286 stream.write(`${ts} [${level}] ${msg}\n`); 287 } 288 289 console.log = (...args: unknown[]) => { 290 origLog(...args); 291 write('info', args); 292 }; 293 console.warn = (...args: unknown[]) => { 294 origWarn(...args); 295 write('warn', args); 296 }; 297 console.error = (...args: unknown[]) => { 298 origError(...args); 299 write('error', args); 300 }; 301 } 302 303 // ── Window ─────────────────────────────────────────────────────── 304 305 let quitConfirmed = false; 306 307 function createWindow() { 308 const mainWindow = new BrowserWindow({ 309 width: 1400, 310 height: 900, 311 webPreferences: { 312 preload: path.join(__dirname, 'preload.js'), 313 }, 314 }); 315 316 mainWindow.webContents.on('did-fail-load', (_event, errorCode, errorDescription) => { 317 console.error('[main] Renderer failed to load:', errorCode, errorDescription); 318 }); 319 320 // Open external links in the user's default browser instead of a new Electron window 321 mainWindow.webContents.setWindowOpenHandler(({ url }) => { 322 void shell.openExternal(url); 323 return { action: 'deny' }; 324 }); 325 326 // On non-macOS, closing the window quits the app — confirm if a review is in progress. 327 // On macOS, closing the window leaves the app running so reviews complete in the background. 328 if (process.platform !== 'darwin') { 329 mainWindow.on('close', (event) => { 330 if (quitConfirmed || activeGenerations.size === 0) return; 331 event.preventDefault(); 332 const count = activeGenerations.size; 333 const response = dialog.showMessageBoxSync(mainWindow, { 334 type: 'question', 335 buttons: ['Cancel', 'Quit Anyway'], 336 defaultId: 0, 337 cancelId: 0, 338 message: `Quit while ${count === 1 ? 'a review is' : `${count} reviews are`} generating?`, 339 detail: `${count === 1 ? 'It' : 'They'} will be cancelled if you quit now.`, 340 }); 341 if (response === 1) { 342 quitConfirmed = true; 343 mainWindow.destroy(); 344 } 345 }); 346 } 347 348 if (MAIN_WINDOW_VITE_DEV_SERVER_URL) { 349 void mainWindow.loadURL(MAIN_WINDOW_VITE_DEV_SERVER_URL); 350 } else { 351 void mainWindow.loadFile(path.join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}/index.html`)); 352 } 353 } 354 355 // ── Update check helpers ───────────────────────────────────── 356 357 let dismissedUpdateVersion: string | null = null; 358 359 async function runUpdateCheck() { 360 const update = await checkForUpdate(app.getVersion(), getResolvedToken() ?? undefined); 361 if (!update) return; 362 if (dismissedUpdateVersion === update.version) return; 363 364 const windows = BrowserWindow.getAllWindows(); 365 for (const win of windows) { 366 win.webContents.send('update-available', update); 367 } 368 } 369 370 let updateInterval: ReturnType<typeof setInterval> | null = null; 371 372 function startUpdateChecks() { 373 setTimeout(() => void runUpdateCheck(), 5_000); 374 updateInterval = setInterval(() => void runUpdateCheck(), 4 * 60 * 60 * 1_000); 375 } 376 377 // ── Auto-review on reviewer assignment ────────────────────────── 378 379 const AUTO_REVIEW_POLL_INTERVAL_MS = 5 * 60 * 1_000; // 5 minutes 380 const AUTO_REVIEW_MAX_AGE_MS = 24 * 60 * 60 * 1_000; // ignore PRs not updated within 24 h 381 382 function getSeenReviewRequestsPath() { 383 return path.join(app.getPath('userData'), 'seen-review-requests.json'); 384 } 385 386 function loadSeenReviewRequests(): Set<string> { 387 try { 388 const data = JSON.parse(fs.readFileSync(getSeenReviewRequestsPath(), 'utf-8')) as string[]; 389 return new Set(data); 390 } catch { 391 return new Set(); 392 } 393 } 394 395 function saveSeenReviewRequests(seen: Set<string>) { 396 fs.writeFileSync(getSeenReviewRequestsPath(), JSON.stringify([...seen], null, 2)); 397 } 398 399 // Loaded once into memory; updated as reviews are triggered 400 let seenReviewRequests = new Set<string>(); 401 402 async function triggerBackgroundReview(prUrl: string, prefs: Preferences): Promise<void> { 403 // cachedToken is guaranteed non-null by the caller (runAutoReviewCheck). 404 console.log(`[auto-review] Starting background review for ${prUrl}`); 405 const reviewId = crypto.randomUUID(); 406 407 try { 408 const octokit = new Octokit({ auth: cachedToken ?? undefined }); 409 const { owner, repo, pullNumber } = parsePrUrl(prUrl); 410 const prData = await getPrMetadata(octokit, owner, repo, pullNumber); 411 412 const abortController = new AbortController(); 413 createPendingHistoryEntry(reviewId, prData.title, prUrl, prData.author, prefs.model, 'open', prData.headSha, true); 414 activeGenerations.set(reviewId, { abortController }); 415 416 const request: GenerateReviewRequest = { 417 prUrl, 418 provider: prefs.provider, 419 model: prefs.model, 420 instructions: prefs.instructions, 421 thinking: prefs.thinking, 422 signalBoost: prefs.signalBoost, 423 smartImports: prefs.smartImports, 424 reviewSuggestions: prefs.reviewSuggestions, 425 }; 426 427 await runBackgroundGeneration(reviewId, request, prData, abortController.signal); 428 429 // Refresh the history list in all open windows 430 broadcastToAllWindows('new-review-in-history'); 431 console.log(`[auto-review] Completed review for ${prUrl}`); 432 } catch (err) { 433 console.error(`[auto-review] Failed to start background review for ${prUrl}:`, err); 434 } 435 } 436 437 async function runAutoReviewCheck() { 438 // Use only in-memory state — never touch the keychain from a background timer. 439 // cachedToken and cachedLogin are populated when the user authenticates via IPC. 440 if (!cachedToken || !cachedLogin) return; 441 const prefs = loadPreferences(); 442 if (!prefs.autoReviewOnRequest) return; 443 444 try { 445 const octokit = new Octokit({ auth: cachedToken }); 446 const prs = await searchPullRequests(octokit, cachedLogin); 447 // searchPullRequests already queries `is:open`, so merged/closed PRs are excluded 448 const reviewRequested = prs.filter((pr) => pr.role === 'review-requested'); 449 const now = Date.now(); 450 451 for (const pr of reviewRequested) { 452 if (!seenReviewRequests.has(pr.url)) { 453 // Always mark as seen so we never trigger the same PR twice 454 seenReviewRequests.add(pr.url); 455 saveSeenReviewRequests(seenReviewRequests); 456 457 // Only review PRs updated in the last 24 h — avoids flooding on first enable 458 const updatedAt = new Date(pr.updatedAt).getTime(); 459 if (now - updatedAt <= AUTO_REVIEW_MAX_AGE_MS) { 460 void triggerBackgroundReview(pr.url, prefs); 461 } else { 462 console.log(`[auto-review] Skipping stale PR (${Math.round((now - updatedAt) / 3_600_000)}h old): ${pr.url}`); 463 } 464 } 465 } 466 } catch (err) { 467 console.error('[auto-review] Poll check failed:', err); 468 } 469 } 470 471 let autoReviewInterval: ReturnType<typeof setInterval> | null = null; 472 473 function startAutoReviewPolling() { 474 if (autoReviewInterval) return; 475 seenReviewRequests = loadSeenReviewRequests(); 476 // Initial check after 10 seconds to let the app fully initialize 477 setTimeout(() => void runAutoReviewCheck(), 10_000); 478 autoReviewInterval = setInterval(() => void runAutoReviewCheck(), AUTO_REVIEW_POLL_INTERVAL_MS); 479 } 480 481 function stopAutoReviewPolling() { 482 if (autoReviewInterval) { 483 clearInterval(autoReviewInterval); 484 autoReviewInterval = null; 485 } 486 } 487 488 // ── Auto-updater (Squirrel) ───────────────────────────────────── 489 function setupAutoUpdater() { 490 if (!app.isPackaged) return; 491 if (process.platform === 'linux') return; 492 493 const feedURL = `https://update.electronjs.org/oddur/gnosis/${process.platform}-${process.arch}/${app.getVersion()}`; 494 try { 495 autoUpdater.setFeedURL({ url: feedURL }); 496 } catch (err) { 497 console.warn('[main] Failed to set autoUpdater feed URL:', err); 498 return; 499 } 500 501 autoUpdater.on('update-downloaded', (_event, _releaseNotes, releaseName) => { 502 const version = releaseName.replace(/^v/, ''); 503 const label = version ? ` ${version}` : ''; 504 console.log(`[main] Update${label} downloaded, will install on exit`); 505 if (loadPreferences().notifications) { 506 const notif = new Notification({ 507 title: 'A new update is ready to install', 508 body: `Gnosis${label} has been downloaded and will be automatically installed on exit`, 509 silent: true, 510 }); 511 notif.show(); 512 } 513 for (const win of BrowserWindow.getAllWindows()) { 514 win.webContents.send('update-ready', version); 515 } 516 }); 517 518 autoUpdater.on('error', (err: Error) => { 519 console.warn('[main] Auto-updater error:', err.message); 520 }); 521 522 // Check for updates automatically — download happens silently 523 autoUpdater.checkForUpdates(); 524 setInterval(() => autoUpdater.checkForUpdates(), 4 * 60 * 60 * 1_000); 525 } 526 527 void app.whenReady().then(() => { 528 setupLogging(); 529 530 // Expose packaged state to preload via env var (before creating windows) 531 process.env.APP_IS_PACKAGED = app.isPackaged ? '1' : '0'; 532 533 // Mark any stale "generating" entries from a previous crash as failed 534 cleanupStaleGeneratingEntries(); 535 applyBinaryOverrides(loadPreferences()); 536 createWindow(); 537 setupAutoUpdater(); 538 539 // GitHub release polling only needed on Linux (no native auto-update) 540 if (process.platform === 'linux') { 541 startUpdateChecks(); 542 } 543 544 startAutoReviewPolling(); 545 546 app.on('activate', () => { 547 if (BrowserWindow.getAllWindows().length === 0) createWindow(); 548 if (!updateInterval && process.platform === 'linux') startUpdateChecks(); 549 if (!autoReviewInterval) startAutoReviewPolling(); 550 }); 551 }); 552 553 app.on('before-quit', (event) => { 554 if (!quitConfirmed && activeGenerations.size > 0) { 555 event.preventDefault(); 556 const count = activeGenerations.size; 557 const win = BrowserWindow.getAllWindows()[0]; 558 const response = dialog.showMessageBoxSync(win, { 559 type: 'question', 560 buttons: ['Cancel', 'Quit Anyway'], 561 defaultId: 0, 562 cancelId: 0, 563 message: `Quit while ${count === 1 ? 'a review is' : `${count} reviews are`} generating?`, 564 detail: `${count === 1 ? 'It' : 'They'} will be cancelled if you quit now.`, 565 }); 566 if (response === 1) { 567 quitConfirmed = true; 568 app.quit(); 569 } 570 return; 571 } 572 // Mark any in-flight generations as failed 573 for (const [id] of activeGenerations) { 574 updateHistoryEntry(id, { status: 'failed', error: 'App quit during generation' }); 575 } 576 activeGenerations.clear(); 577 }); 578 579 app.on('window-all-closed', () => { 580 if (updateInterval) { 581 clearInterval(updateInterval); 582 updateInterval = null; 583 } 584 stopAutoReviewPolling(); 585 if (process.platform !== 'darwin') app.quit(); 586 }); 587 588 // ── Review history helpers ─────────────────────────────────────── 589 590 function getReviewsDir() { 591 return path.join(app.getPath('userData'), 'reviews'); 592 } 593 594 function getReviewsIndexPath() { 595 return path.join(app.getPath('userData'), 'reviews-index.json'); 596 } 597 598 function ensureReviewsDir() { 599 const dir = getReviewsDir(); 600 if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); 601 } 602 603 function readReviewsIndex(): ReviewHistoryEntry[] { 604 try { 605 const entries = JSON.parse(fs.readFileSync(getReviewsIndexPath(), 'utf-8')) as ReviewHistoryEntry[]; 606 // Backward compat: entries without status are completed 607 return entries.map((e) => ({ ...e, status: e.status ?? 'completed' })); 608 } catch { 609 return []; 610 } 611 } 612 613 // ── Background generation tracking ────────────────────────────── 614 615 const activeGenerations = new Map<string, { abortController?: AbortController }>(); 616 617 function createPendingHistoryEntry( 618 id: string, 619 prTitle: string, 620 prUrl: string, 621 author: string, 622 model?: ModelId, 623 prState?: 'open' | 'merged' | 'closed', 624 prHeadSha?: string, 625 unread?: boolean 626 ): void { 627 ensureReviewsDir(); 628 const entry: ReviewHistoryEntry = { 629 id, 630 prTitle, 631 prUrl, 632 author, 633 riskLevel: 'low', // placeholder until generation completes 634 status: 'generating', 635 model, 636 savedAt: new Date().toISOString(), 637 prState, 638 prHeadSha, 639 ...(unread ? { unread: true } : {}), 640 }; 641 const index = readReviewsIndex(); 642 index.unshift(entry); 643 fs.writeFileSync(getReviewsIndexPath(), JSON.stringify(index, null, 2)); 644 } 645 646 function updateHistoryEntry(id: string, updates: Partial<ReviewHistoryEntry>): void { 647 const index = readReviewsIndex(); 648 const idx = index.findIndex((e) => e.id === id); 649 if (idx === -1) return; 650 index[idx] = { ...index[idx], ...updates }; 651 fs.writeFileSync(getReviewsIndexPath(), JSON.stringify(index, null, 2)); 652 } 653 654 function broadcastToAllWindows(channel: string, ...args: unknown[]): void { 655 for (const win of BrowserWindow.getAllWindows()) { 656 win.webContents.send(channel, ...args); 657 } 658 } 659 660 function cleanupStaleGeneratingEntries(): void { 661 const index = readReviewsIndex(); 662 let changed = false; 663 for (const entry of index) { 664 if (entry.status === 'generating') { 665 entry.status = 'failed'; 666 entry.error = 'Generation was interrupted'; 667 changed = true; 668 } 669 } 670 if (changed) { 671 fs.writeFileSync(getReviewsIndexPath(), JSON.stringify(index, null, 2)); 672 } 673 } 674 675 // ── Preferences helpers ───────────────────────────────────────── 676 677 function getPreferencesPath() { 678 return path.join(app.getPath('userData'), 'preferences.json'); 679 } 680 681 const DEFAULT_PREFERENCES: Preferences = { 682 instructions: '', 683 provider: 'claude', 684 model: 'claude-opus-4-6', 685 thinking: true, 686 signalBoost: true, 687 smartImports: true, 688 reviewSuggestions: true, 689 enableTools: false, 690 enableWebResearch: false, 691 autoReviewOnRequest: false, 692 codeTheme: 'aurora-x', 693 codeFont: 'jetbrains-mono', 694 claudePath: '', 695 geminiPath: '', 696 notifications: true, 697 diffLayout: 'unified', 698 includeAllFiles: true, 699 reviewSignature: true, 700 }; 701 702 function applyBinaryOverrides(prefs: Preferences): void { 703 setBinaryOverride('claude', prefs.claudePath); 704 setBinaryOverride('gemini', prefs.geminiPath); 705 } 706 707 function loadPreferences(): Preferences { 708 try { 709 const stored = JSON.parse(fs.readFileSync(getPreferencesPath(), 'utf-8')) as Partial<Preferences>; 710 return { ...DEFAULT_PREFERENCES, ...stored }; 711 } catch { 712 return { ...DEFAULT_PREFERENCES }; 713 } 714 } 715 716 function savePreferences(prefs: Preferences): void { 717 fs.writeFileSync(getPreferencesPath(), JSON.stringify(prefs, null, 2)); 718 } 719 720 // ── MCP tools constants ───────────────────────────────────────── 721 722 const ALLOWED_TOOLS = [ 723 'WebFetch', 724 'WebSearch', 725 'mcp__github__get_file_contents', 726 'mcp__github__get_issue', 727 'mcp__github__list_issues', 728 'mcp__github__get_pull_request', 729 'mcp__github__get_pull_request_files', 730 'mcp__github__get_pull_request_comments', 731 'mcp__github__get_pull_request_reviews', 732 'mcp__github__list_commits', 733 'mcp__github__search_code', 734 'mcp__github__search_issues', 735 ]; 736 737 const WEB_ONLY_TOOLS = ['WebFetch', 'WebSearch']; 738 739 // ── IPC handlers ──────────────────────────────────────────────── 740 741 ipcMain.handle('dismiss-update', (_event, version: string) => { 742 dismissedUpdateVersion = version; 743 }); 744 745 ipcMain.handle('open-external', (_event, url: string) => { 746 try { 747 if (new URL(url).protocol === 'https:') { 748 void shell.openExternal(url); 749 } 750 } catch { 751 // invalid URL — ignore 752 } 753 }); 754 755 ipcMain.handle('open-logs-directory', () => { 756 const logsDir = getLogsDir(); 757 if (!fs.existsSync(logsDir)) fs.mkdirSync(logsDir, { recursive: true }); 758 void shell.openPath(logsDir); 759 }); 760 761 ipcMain.handle('open-review-prompt', (_event, id: string) => { 762 const promptPath = path.join(getReviewsDir(), `${id}-prompt.md`); 763 if (fs.existsSync(promptPath)) { 764 void shell.openPath(promptPath); 765 } 766 }); 767 768 // Backward-compat shim — renderer still calls getConfig to check if signed in 769 ipcMain.handle('get-config', () => { 770 const token = getResolvedToken(); 771 return { githubToken: token }; 772 }); 773 774 ipcMain.handle('start-oauth', async () => { 775 await runOAuthFlow(); 776 }); 777 778 ipcMain.handle('save-pat', async (_event, token: string) => { 779 const trimmed = token.trim(); 780 if (!trimmed) throw new Error('Token must not be empty'); 781 const login = await validateAndFetchLogin(trimmed); 782 persistToken(trimmed); 783 cachedToken = trimmed; 784 cachedLogin = login; 785 return login; 786 }); 787 788 ipcMain.handle('get-auth-state', async () => { 789 const token = getResolvedToken(); 790 if (!token) return { authenticated: false, login: null }; 791 792 if (!cachedLogin) { 793 try { 794 cachedLogin = await fetchGitHubLogin(token); 795 } catch { 796 // token may be invalid 797 return { authenticated: false, login: null }; 798 } 799 } 800 801 return { authenticated: true, login: cachedLogin }; 802 }); 803 804 ipcMain.handle('sign-out', () => { 805 cachedToken = null; 806 cachedLogin = null; 807 deleteStoredToken(); 808 }); 809 810 ipcMain.handle('search-pull-requests', async () => { 811 const token = getResolvedToken(); 812 if (!token || !cachedLogin) throw new Error('Not authenticated'); 813 const octokit = new Octokit({ auth: token }); 814 return searchPullRequests(octokit, cachedLogin); 815 }); 816 817 ipcMain.handle('load-preferences', () => { 818 return loadPreferences(); 819 }); 820 821 ipcMain.handle('save-preferences', (_event, prefs: Preferences) => { 822 savePreferences(prefs); 823 applyBinaryOverrides(prefs); 824 // Restart polling so the new autoReviewOnRequest value takes effect immediately 825 stopAutoReviewPolling(); 826 if (prefs.autoReviewOnRequest) startAutoReviewPolling(); 827 }); 828 829 ipcMain.handle('detect-binary-path', (_event, name: string) => { 830 const extra = name === 'claude' ? [`${os.homedir()}/.volta/bin/claude`, `${os.homedir()}/.local/bin/claude`] : []; 831 return detectBinaryPath(name, extra); 832 }); 833 834 ipcMain.handle('check-cli-installed', (_event, provider: string) => { 835 const extra = provider === 'claude' ? [`${os.homedir()}/.volta/bin/claude`, `${os.homedir()}/.local/bin/claude`] : []; 836 const resolved = resolveBinaryPath(provider, extra); 837 const installed = path.isAbsolute(resolved) && fs.existsSync(resolved); 838 return { installed, resolvedPath: resolved }; 839 }); 840 841 ipcMain.handle('list-reviews', () => { 842 return readReviewsIndex(); 843 }); 844 845 ipcMain.handle('load-review', async (_event, id: string) => { 846 const reviewPath = path.join(getReviewsDir(), `${id}.json`); 847 const review = JSON.parse(fs.readFileSync(reviewPath, 'utf-8')) as ReviewGuide; 848 const prefs = loadPreferences(); 849 await reRenderAllHunks(review, prefs.codeTheme); 850 return review; 851 }); 852 853 ipcMain.handle('re-render-hunks', async (_event, review: ReviewGuide) => { 854 const prefs = loadPreferences(); 855 await reRenderAllHunks(review, prefs.codeTheme); 856 return review; 857 }); 858 859 ipcMain.handle('mark-review-read', (_event, id: string) => { 860 const index = readReviewsIndex().map((e) => (e.id === id ? { ...e, unread: false } : e)); 861 fs.writeFileSync(getReviewsIndexPath(), JSON.stringify(index, null, 2)); 862 }); 863 864 ipcMain.handle('delete-review', (_event, id: string) => { 865 const reviewPath = path.join(getReviewsDir(), `${id}.json`); 866 const promptPath = path.join(getReviewsDir(), `${id}-prompt.md`); 867 if (fs.existsSync(reviewPath)) fs.unlinkSync(reviewPath); 868 if (fs.existsSync(promptPath)) fs.unlinkSync(promptPath); 869 const index = readReviewsIndex().filter((e) => e.id !== id); 870 fs.writeFileSync(getReviewsIndexPath(), JSON.stringify(index, null, 2)); 871 }); 872 873 ipcMain.handle('delete-all-reviews', () => { 874 const dir = getReviewsDir(); 875 if (fs.existsSync(dir)) { 876 for (const file of fs.readdirSync(dir)) { 877 if (file.endsWith('.json') || file.endsWith('-prompt.md')) fs.unlinkSync(path.join(dir, file)); 878 } 879 } 880 fs.writeFileSync(getReviewsIndexPath(), JSON.stringify([], null, 2)); 881 }); 882 883 ipcMain.handle( 884 'check-pr-freshness', 885 async (_event, prUrl: string, headSha: string | undefined): Promise<FreshnessResult> => { 886 if (!headSha) { 887 return { status: 'unknown', reason: 'Review has no stored head SHA' }; 888 } 889 890 const token = getResolvedToken(); 891 if (!token) return { status: 'unknown', reason: 'Not signed in' }; 892 const octokit = new Octokit({ auth: token }); 893 const { owner, repo, pullNumber } = parsePrUrl(prUrl); 894 895 try { 896 const prData = await getPrMetadata(octokit, owner, repo, pullNumber); 897 const currentSha = prData.headSha; 898 899 if (currentSha === headSha) { 900 return { status: 'current' }; 901 } 902 903 try { 904 const { data } = await octokit.repos.compareCommits({ 905 owner, 906 repo, 907 base: headSha, 908 head: currentSha, 909 }); 910 911 // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- GitHub API defensive 912 const commits = (data.commits ?? []).slice(0, 50).map((c) => ({ 913 sha: c.sha, 914 // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- GitHub API defensive 915 message: (c.commit.message ?? '').split('\n')[0], 916 authorLogin: c.author?.login ?? c.commit.author?.name ?? 'unknown', 917 authorDate: c.commit.author?.date ?? '', 918 })); 919 920 return { 921 status: 'stale', 922 // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- GitHub API defensive 923 aheadBy: data.ahead_by ?? commits.length, 924 commits, 925 }; 926 } catch (compareErr: unknown) { 927 const status = (compareErr as { status?: number }).status; 928 if (status === 404) { 929 return { status: 'force-pushed' }; 930 } 931 throw compareErr; 932 } 933 } catch (err: unknown) { 934 const message = err instanceof Error ? err.message : 'Unknown error'; 935 return { status: 'unknown', reason: message }; 936 } 937 } 938 ); 939 940 ipcMain.handle('get-pr-status', async (_event, prUrl: string): Promise<PrStatus> => { 941 const token = getResolvedToken(); 942 const octokit = new Octokit({ auth: token ?? undefined }); 943 const { owner, repo, pullNumber } = parsePrUrl(prUrl); 944 945 const [prData, reviewSummary] = await Promise.all([ 946 getPrMetadata(octokit, owner, repo, pullNumber), 947 getReviewStatus(octokit, owner, repo, pullNumber), 948 ]); 949 950 const ciStatus = await getCiStatus(octokit, owner, repo, prData.headSha).catch(() => ({ 951 checks: [] as CiCheck[], 952 conclusion: 'neutral' as const, 953 })); 954 955 return { 956 labels: prData.labels, 957 mergeable: prData.mergeable, 958 isDraft: prData.isDraft, 959 ciChecks: ciStatus.checks, 960 ciConclusion: ciStatus.conclusion, 961 reviewSummary, 962 baseBranch: prData.baseBranch, 963 commitCount: prData.commitCount, 964 requestedReviewers: prData.requestedReviewers, 965 requestedTeams: prData.requestedTeams, 966 mergeableState: prData.mergeableState, 967 autoMerge: prData.autoMerge, 968 milestone: prData.milestone, 969 }; 970 }); 971 972 ipcMain.handle( 973 'get-pr-state', 974 async (_event, prUrl: string): Promise<{ prState: 'open' | 'merged' | 'closed'; headSha: string }> => { 975 const token = getResolvedToken(); 976 const octokit = new Octokit({ auth: token ?? undefined }); 977 const { owner, repo, pullNumber } = parsePrUrl(prUrl); 978 const prData = await getPrMetadata(octokit, owner, repo, pullNumber); 979 const prState = prData.merged ? 'merged' : prData.state === 'open' ? 'open' : 'closed'; 980 return { prState, headSha: prData.headSha }; 981 } 982 ); 983 984 ipcMain.handle('get-pr-files', async (_event, prUrl: string): Promise<ChangedFile[]> => { 985 const token = getResolvedToken(); 986 const octokit = new Octokit({ auth: token ?? undefined }); 987 const { owner, repo, pullNumber } = parsePrUrl(prUrl); 988 return getChangedFiles(octokit, owner, repo, pullNumber); 989 }); 990 991 // ── Background review generation ──────────────────────────────── 992 993 async function runBackgroundGeneration( 994 reviewId: string, 995 request: GenerateReviewRequest, 996 prData: Awaited<ReturnType<typeof getPrMetadata>>, 997 signal: AbortSignal 998 ): Promise<void> { 999 const { prUrl, provider, model, instructions, thinking, signalBoost, smartImports, reviewSuggestions, webResearch } = 1000 request; 1001 1002 try { 1003 const token = getResolvedToken(); 1004 const octokit = new Octokit({ auth: token ?? undefined }); 1005 const { owner, repo, pullNumber } = parsePrUrl(prUrl); 1006 1007 broadcastToAllWindows('review-phase', { reviewId, phase: 'Fetching PR data' }); 1008 1009 const [diff, allChangedFiles] = await Promise.all([ 1010 getPrDiff(octokit, owner, repo, pullNumber), 1011 getChangedFiles(octokit, owner, repo, pullNumber), 1012 ]); 1013 1014 // Apply user exclusions before any other processing 1015 const userExcludedSet = new Set(request.excludedFiles ?? []); 1016 const changedFiles = 1017 userExcludedSet.size > 0 ? allChangedFiles.filter((f) => !userExcludedSet.has(f.filename)) : allChangedFiles; 1018 const userFilteredDiff = userExcludedSet.size > 0 ? filterDiff(diff, userExcludedSet) : diff; 1019 1020 if (changedFiles.length === 0) { 1021 throw new Error('PR has no changed files'); 1022 } 1023 1024 // Filter out generated/lock files early to avoid token budget blowup 1025 const { normalFiles, generatedFiles } = classifyFiles(changedFiles); 1026 const generatedFilenames = new Set(generatedFiles.map((f) => f.filename)); 1027 const filteredDiff = filterDiff(userFilteredDiff, generatedFilenames); 1028 const excludedFilesSummary = buildExcludedFilesSummary(generatedFiles); 1029 1030 if (generatedFiles.length > 0) { 1031 console.log( 1032 `[main] Filtered ${generatedFiles.length} generated file(s):`, 1033 generatedFiles.map((f) => f.filename) 1034 ); 1035 } 1036 1037 const baseRef = prData.baseBranch; 1038 const headRef = prData.headSha; 1039 1040 broadcastToAllWindows('review-phase', { reviewId, phase: 'Fetching file contents' }); 1041 1042 const fileContents: Record<string, string> = {}; 1043 const headFileContents: Record<string, string> = {}; 1044 const concurrency = 5; 1045 const filesToFetch = normalFiles.filter((f) => f.status !== 'deleted'); 1046 const filesToFetchBase = normalFiles.filter((f) => f.status !== 'added'); 1047 1048 for (let i = 0; i < Math.max(filesToFetch.length, filesToFetchBase.length); i += concurrency) { 1049 const headBatch = filesToFetch.slice(i, i + concurrency); 1050 const baseBatch = filesToFetchBase.slice(i, i + concurrency); 1051 await Promise.all([ 1052 ...headBatch.map(async (f) => { 1053 const content = await getFileContent(octokit, owner, repo, f.filename, headRef); 1054 if (content !== null) headFileContents[f.filename] = content; 1055 }), 1056 ...baseBatch.map(async (f) => { 1057 const content = await getFileContent(octokit, owner, repo, f.filename, baseRef); 1058 if (content !== null) fileContents[f.filename] = content; 1059 }), 1060 ]); 1061 } 1062 1063 broadcastToAllWindows('review-phase', { reviewId, phase: 'Resolving imports' }); 1064 1065 const allFileContents = { ...fileContents, ...headFileContents }; 1066 const neighborFiles = await getNeighborFiles( 1067 octokit, 1068 owner, 1069 repo, 1070 normalFiles.map((f) => f.filename), 1071 allFileContents, 1072 baseRef, 1073 smartImports ? provider : undefined 1074 ); 1075 1076 broadcastToAllWindows('review-phase', { reviewId, phase: 'Building context' }); 1077 1078 // Parse diff into indexed hunks and build expanded diff (using filtered diff) 1079 const indexedHunks = buildIndexedHunks(filteredDiff, fileContents, headFileContents); 1080 const expandedDiff = expandFullDiff(filteredDiff, fileContents, headFileContents); 1081 const hunkIndex = formatHunkIndexForPrompt(indexedHunks); 1082 1083 const contextPackage = buildContextPackage( 1084 prData, 1085 expandedDiff, 1086 changedFiles, 1087 fileContents, 1088 headFileContents, 1089 neighborFiles, 1090 hunkIndex, 1091 excludedFilesSummary 1092 ); 1093 1094 broadcastToAllWindows('review-phase', { reviewId, phase: 'Generating review' }); 1095 1096 console.log(`[main] Generating review guide (${reviewId})...`); 1097 const generationStart = Date.now(); 1098 1099 const prefs = loadPreferences(); 1100 let mcpConfigPath: string | undefined; 1101 let allowedTools: string[] | undefined; 1102 1103 if (prefs.enableTools && provider === 'claude') { 1104 if (token) { 1105 mcpConfigPath = writeMcpConfig(token); 1106 allowedTools = ALLOWED_TOOLS; 1107 } else { 1108 allowedTools = WEB_ONLY_TOOLS; 1109 } 1110 } else if (webResearch && provider === 'claude') { 1111 allowedTools = WEB_ONLY_TOOLS; 1112 } 1113 1114 let aiResult; 1115 let lastStreamPhase: string | null = null; 1116 try { 1117 aiResult = await generateReviewGuide( 1118 contextPackage, 1119 prUrl, 1120 provider, 1121 model, 1122 instructions, 1123 (chunk, isThinking) => { 1124 const phase = isThinking ? 'Thinking' : 'Generating review'; 1125 if (phase !== lastStreamPhase) { 1126 lastStreamPhase = phase; 1127 broadcastToAllWindows('review-phase', { reviewId, phase }); 1128 } 1129 broadcastToAllWindows('review-progress', { reviewId, chunk, isThinking }); 1130 }, 1131 thinking ?? false, 1132 signalBoost ?? false, 1133 mcpConfigPath, 1134 allowedTools, 1135 reviewSuggestions ?? true, 1136 webResearch ?? false, 1137 (toolName) => broadcastToAllWindows('review-tool-use', { reviewId, toolName }), 1138 (system, userMessage) => { 1139 broadcastToAllWindows('review-stats', { 1140 reviewId, 1141 inputBytes: system.length + userMessage.length, 1142 }); 1143 try { 1144 ensureReviewsDir(); 1145 fs.writeFileSync( 1146 path.join(getReviewsDir(), `${reviewId}-prompt.md`), 1147 `# System Prompt\n\n${system}\n\n# User Message\n\n${userMessage}\n` 1148 ); 1149 } catch { 1150 // Best-effort — don't fail the review if prompt save fails 1151 } 1152 }, 1153 signal 1154 ); 1155 } finally { 1156 if (mcpConfigPath) cleanupMcpConfig(mcpConfigPath); 1157 } 1158 1159 const generationDurationMs = Date.now() - generationStart; 1160 1161 // Append raw model response to prompt file (best-effort) 1162 try { 1163 const promptPath = path.join(getReviewsDir(), `${reviewId}-prompt.md`); 1164 if (fs.existsSync(promptPath)) { 1165 fs.appendFileSync( 1166 promptPath, 1167 `\n---\n\n# Model Response\n\n\`\`\`json\n${JSON.stringify(aiResult, null, 2)}\n\`\`\`\n` 1168 ); 1169 } 1170 } catch { 1171 // Best-effort 1172 } 1173 1174 // Resolve hunk IDs → real DiffHunk objects 1175 const hunkMap = new Map(indexedHunks.map((h) => [h.id, h])); 1176 const assignedIds = new Set<string>(); 1177 1178 const resolvedSlides: Slide[] = aiResult.slides.map((aiSlide) => { 1179 // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- AI response may omit fields 1180 const ids = aiSlide.diffHunkIds ?? []; 1181 const diffHunks: DiffHunk[] = ids 1182 .filter((id: string) => hunkMap.has(id) && !assignedIds.has(id)) 1183 .map((id: string) => { 1184 assignedIds.add(id); 1185 const h = hunkMap.get(id); 1186 if (!h) throw new Error(`Hunk ${id} not found in index`); 1187 return { 1188 filePath: h.filePath, 1189 hunkHeader: h.expandedHunkHeader, 1190 content: h.expandedContent, 1191 language: h.language, 1192 renderedHtml: '', 1193 }; 1194 }); 1195 1196 return { 1197 id: aiSlide.id, 1198 slideNumber: aiSlide.slideNumber, 1199 title: aiSlide.title, 1200 slideType: aiSlide.slideType, 1201 narrative: aiSlide.narrative, 1202 reviewFocus: aiSlide.reviewFocus, 1203 diffHunks: sortDiffHunks(diffHunks), 1204 // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- AI response may omit fields 1205 contextSnippets: aiSlide.contextSnippets ?? [], 1206 // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- AI response may omit fields 1207 affectedFiles: aiSlide.affectedFiles ?? [], 1208 // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- AI response may omit fields 1209 dependsOn: aiSlide.dependsOn ?? [], 1210 mermaidDiagram: aiSlide.mermaidDiagram, 1211 reviewChecks: aiSlide.reviewChecks, 1212 }; 1213 }); 1214 1215 // Sanitize reviewChecks — clear invalid file/line refs so they render as non-clickable 1216 for (const slide of resolvedSlides) { 1217 if (!Array.isArray(slide.reviewChecks)) continue; 1218 const slideFilePaths = new Set(slide.diffHunks.map((h) => h.filePath)); 1219 1220 for (const check of slide.reviewChecks) { 1221 if (!check.filePath || !slideFilePaths.has(check.filePath)) { 1222 delete check.filePath; 1223 delete check.startLine; 1224 continue; 1225 } 1226 if (check.startLine == null || check.startLine <= 0) { 1227 delete check.filePath; 1228 delete check.startLine; 1229 continue; 1230 } 1231 // Verify startLine falls within one of the file's hunk ranges 1232 const fileHunks = slide.diffHunks.filter((h) => h.filePath === check.filePath); 1233 const lineExists = fileHunks.some((hunk) => { 1234 const lines = parseDiffLines(hunk.hunkHeader, hunk.content); 1235 return lines.some((l) => l.lineNumber === check.startLine); 1236 }); 1237 if (!lineExists) { 1238 delete check.filePath; 1239 delete check.startLine; 1240 } 1241 } 1242 } 1243 1244 // Catch-all slide for unassigned hunks 1245 const unassigned = indexedHunks.filter((h) => !assignedIds.has(h.id)); 1246 if (unassigned.length > 0) { 1247 const otherHunks: DiffHunk[] = unassigned.map((h) => ({ 1248 filePath: h.filePath, 1249 hunkHeader: h.expandedHunkHeader, 1250 content: h.expandedContent, 1251 language: h.language, 1252 renderedHtml: '', 1253 })); 1254 1255 resolvedSlides.push({ 1256 id: 'other-changes', 1257 slideNumber: resolvedSlides.length + 1, 1258 title: 'Other changes', 1259 slideType: 'refactor', 1260 narrative: 'Additional changes not covered in previous slides.', 1261 reviewFocus: null, 1262 diffHunks: sortDiffHunks(otherHunks), 1263 contextSnippets: [], 1264 affectedFiles: [...new Set(unassigned.map((h) => h.filePath))], 1265 dependsOn: [], 1266 mermaidDiagram: null, 1267 }); 1268 } 1269 1270 const reviewGuide: ReviewGuide = { 1271 prTitle: aiResult.prTitle || prData.title, 1272 prDescription: aiResult.prDescription || prData.description, 1273 prUrl, 1274 author: aiResult.author || prData.author, 1275 summary: aiResult.summary, 1276 riskLevel: aiResult.riskLevel, 1277 riskRationale: aiResult.riskRationale, 1278 totalFilesChanged: changedFiles.length, 1279 totalLinesChanged: changedFiles.reduce((sum, f) => sum + f.additions + f.deletions, 0), 1280 neighborFileCount: Object.keys(neighborFiles).length, 1281 excludedFiles: generatedFiles.length > 0 ? generatedFiles.map((f) => f.filename) : undefined, 1282 generationDurationMs, 1283 slides: resolvedSlides, 1284 headSha: prData.headSha, 1285 webSources: aiResult.webSources, 1286 }; 1287 1288 broadcastToAllWindows('review-phase', { reviewId, phase: 'Rendering' }); 1289 1290 // Render syntax-highlighted HTML for each hunk 1291 const codeTheme = loadPreferences().codeTheme; 1292 for (const slide of reviewGuide.slides) { 1293 for (const hunk of slide.diffHunks) { 1294 try { 1295 hunk.renderedHtml = await renderDiffHunk(hunk.content, hunk.language, codeTheme, hunk.hunkHeader); 1296 } catch (err) { 1297 console.warn(`[main] Failed to render hunk for ${hunk.filePath}:`, err); 1298 hunk.renderedHtml = `<pre class="diff-block">${hunk.content}</pre>`; 1299 } 1300 } 1301 } 1302 1303 // Save review JSON and update history entry to completed 1304 fs.writeFileSync(path.join(getReviewsDir(), `${reviewId}.json`), JSON.stringify(reviewGuide)); 1305 updateHistoryEntry(reviewId, { 1306 status: 'completed', 1307 riskLevel: reviewGuide.riskLevel, 1308 generationDurationMs, 1309 }); 1310 1311 broadcastToAllWindows('review-completed', { reviewId }); 1312 1313 // Desktop notification 1314 if (loadPreferences().notifications) { 1315 const notif = new Notification({ 1316 title: 'Review ready', 1317 body: prData.title, 1318 silent: true, 1319 }); 1320 notif.on('click', () => { 1321 broadcastToAllWindows('review-navigate', { reviewId }); 1322 const windows = BrowserWindow.getAllWindows(); 1323 if (windows.length > 0) { 1324 const win = windows[0]; 1325 if (win.isMinimized()) win.restore(); 1326 win.focus(); 1327 } 1328 }); 1329 notif.show(); 1330 } 1331 1332 console.log(`[main] Review ${reviewId} completed in ${formatMs(generationDurationMs)}`); 1333 } catch (err) { 1334 const isCancelled = err instanceof Error && err.message === 'GNOSIS_CANCELLED'; 1335 const errorMessage = isCancelled ? 'Cancelled' : err instanceof Error ? err.message : 'Unknown error'; 1336 if (!isCancelled) console.error(`[main] Review ${reviewId} failed:`, errorMessage); 1337 1338 updateHistoryEntry(reviewId, { 1339 status: 'failed', 1340 error: errorMessage, 1341 }); 1342 1343 broadcastToAllWindows('review-failed', { reviewId, error: errorMessage }); 1344 } finally { 1345 activeGenerations.delete(reviewId); 1346 } 1347 } 1348 1349 function formatMs(ms: number): string { 1350 const s = Math.round(ms / 1000); 1351 return s >= 60 ? `${Math.floor(s / 60)}m ${s % 60}s` : `${s}s`; 1352 } 1353 1354 ipcMain.handle('start-review', async (_event, request: GenerateReviewRequest): Promise<StartReviewResult> => { 1355 const token = getResolvedToken(); 1356 const octokit = new Octokit({ auth: token ?? undefined }); 1357 const { owner, repo, pullNumber } = parsePrUrl(request.prUrl); 1358 1359 // Fetch PR metadata (fast, single API call) 1360 const prData = await getPrMetadata(octokit, owner, repo, pullNumber); 1361 1362 const reviewId = crypto.randomUUID(); 1363 const prState = prData.merged ? 'merged' : prData.state === 'open' ? 'open' : 'closed'; 1364 1365 // Create pending history entry 1366 createPendingHistoryEntry( 1367 reviewId, 1368 prData.title, 1369 request.prUrl, 1370 prData.author, 1371 request.model, 1372 prState, 1373 prData.headSha 1374 ); 1375 1376 // Track and fire off background generation (no await) 1377 const abortController = new AbortController(); 1378 activeGenerations.set(reviewId, { abortController }); 1379 void runBackgroundGeneration(reviewId, request, prData, abortController.signal); 1380 1381 return { 1382 reviewId, 1383 prTitle: prData.title, 1384 prUrl: request.prUrl, 1385 author: prData.author, 1386 }; 1387 }); 1388 1389 ipcMain.handle('cancel-review', (_event, reviewId: string) => { 1390 const gen = activeGenerations.get(reviewId); 1391 if (gen?.abortController) { 1392 gen.abortController.abort('User cancelled'); 1393 } 1394 }); 1395 1396 ipcMain.handle('send-slide-chat', async (_event, req: SendSlideChatRequest) => { 1397 const chatProvider = getProvider(req.provider); 1398 const systemPrompt = buildSlideChatSystemPrompt(); 1399 const userMessage = buildSlideChatUserMessage(req); 1400 1401 const prefs = loadPreferences(); 1402 let mcpConfigPath: string | undefined; 1403 let allowedTools: string[] | undefined; 1404 1405 if (prefs.enableTools && req.provider === 'claude') { 1406 const token = getResolvedToken(); 1407 if (token) { 1408 mcpConfigPath = writeMcpConfig(token); 1409 allowedTools = ALLOWED_TOOLS; 1410 } else { 1411 allowedTools = WEB_ONLY_TOOLS; 1412 } 1413 } else if (prefs.enableWebResearch && req.provider === 'claude') { 1414 allowedTools = WEB_ONLY_TOOLS; 1415 } 1416 1417 try { 1418 const result = await chatProvider.generate({ 1419 content: userMessage, 1420 systemPrompt, 1421 model: req.model, 1422 thinking: false, 1423 onChunk: (chunk, isThinking) => { 1424 if (!isThinking) { 1425 _event.sender.send('chat-progress', { chunk }); 1426 } 1427 }, 1428 onToolUse: (toolName) => _event.sender.send('chat-tool-use', { toolName }), 1429 mcpConfigPath, 1430 allowedTools, 1431 }); 1432 return result; 1433 } finally { 1434 if (mcpConfigPath) cleanupMcpConfig(mcpConfigPath); 1435 } 1436 }); 1437 1438 ipcMain.handle('submit-review', async (_event, req: SubmitReviewRequest) => { 1439 const token = getResolvedToken(); 1440 const octokit = new Octokit({ auth: token ?? undefined }); 1441 const { owner, repo, pullNumber } = parsePrUrl(req.prUrl); 1442 1443 // Fetch actual PR file patches to validate line numbers. 1444 // The AI-generated hunks have expanded context (10 lines vs GitHub's 3), 1445 // so some lines may not be in the real diff. 1446 const prFiles = await octokit.paginate(octokit.pulls.listFiles, { 1447 owner, 1448 repo, 1449 pull_number: pullNumber, 1450 per_page: 100, 1451 }); 1452 1453 const validLinesByFile = new Map<string, Set<string>>(); 1454 for (const file of prFiles) { 1455 if (file.patch) { 1456 validLinesByFile.set(file.filename, parsePatchValidLines(file.patch)); 1457 } 1458 } 1459 1460 // Partition comments into valid (can be posted as line comments) and 1461 // dropped (line not in GitHub's diff — folded into review body instead) 1462 const validComments: typeof req.comments = []; 1463 const droppedComments: typeof req.comments = []; 1464 1465 for (const c of req.comments) { 1466 const validLines = validLinesByFile.get(c.path); 1467 const key = `${c.line}:${c.side}`; 1468 if (validLines?.has(key)) { 1469 validComments.push(c); 1470 } else { 1471 droppedComments.push(c); 1472 } 1473 } 1474 1475 // If some comments can't be posted inline, append them to the review body 1476 let reviewBody = req.body; 1477 if (droppedComments.length > 0) { 1478 const droppedText = droppedComments.map((c) => `**${c.path}:${c.line}** — ${c.body}`).join('\n\n'); 1479 const suffix = `\n\n---\n_${droppedComments.length} comment(s) could not be posted inline (lines outside the diff range):_\n\n${droppedText}`; 1480 reviewBody = (reviewBody || '') + suffix; 1481 } 1482 1483 const prefs = loadPreferences(); 1484 if (prefs.reviewSignature) { 1485 reviewBody = (reviewBody || '') + '\n\n---\n_Reviewed using [gnosis.to](https://gnosis.to)_'; 1486 } 1487 1488 const { data } = await octokit.pulls.createReview({ 1489 owner, 1490 repo, 1491 pull_number: pullNumber, 1492 commit_id: req.headSha, 1493 body: reviewBody, 1494 event: req.event, 1495 comments: validComments.map((c) => ({ 1496 path: c.path, 1497 line: c.line, 1498 side: c.side, 1499 body: c.body, 1500 })), 1501 }); 1502 1503 return { reviewUrl: data.html_url, droppedCommentCount: droppedComments.length }; 1504 });