/ src / main.ts
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  });