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