diff --git a/apps/desktop/biome.jsonc b/apps/desktop/biome.jsonc index 91c34518ab..3de881feda 100644 --- a/apps/desktop/biome.jsonc +++ b/apps/desktop/biome.jsonc @@ -45,7 +45,8 @@ "noProcessEnv": "off", "useNodejsImportProtocol": "off", "useImportType": "off", - "useTemplate": "off" + "useTemplate": "off", + "noNonNullAssertion": "warn" }, "suspicious": { "recommended": true, diff --git a/apps/desktop/src/__mocks__/electron.ts b/apps/desktop/src/__mocks__/electron.ts index e5569f6893..821bf5635c 100644 --- a/apps/desktop/src/__mocks__/electron.ts +++ b/apps/desktop/src/__mocks__/electron.ts @@ -7,17 +7,20 @@ import { EventEmitter } from 'events'; // Mock app export const app = { getPath: vi.fn((name: string) => { + const os = require('os'); + const path = require('path'); const paths: Record = { - userData: '/tmp/test-app-data', - home: '/tmp/test-home', - temp: '/tmp' + userData: path.join(os.homedir(), '.aperant-test-data'), + home: os.homedir(), + temp: os.tmpdir() }; - return paths[name] || '/tmp'; + return paths[name] || path.join(os.homedir(), '.aperant-test'); }), getAppPath: vi.fn(() => '/tmp/test-app'), getVersion: vi.fn(() => '0.1.0'), isPackaged: false, on: vi.fn(), + whenReady: vi.fn(() => Promise.resolve()), quit: vi.fn() }; diff --git a/apps/desktop/src/__tests__/integration/claude-profile-ipc.test.ts b/apps/desktop/src/__tests__/integration/claude-profile-ipc.test.ts index 8c6d0b8d4d..28f0afdc85 100644 --- a/apps/desktop/src/__tests__/integration/claude-profile-ipc.test.ts +++ b/apps/desktop/src/__tests__/integration/claude-profile-ipc.test.ts @@ -209,7 +209,6 @@ describe('Claude Profile IPC Integration', () => { await handleProfileSave?.(null, profile); - // biome-ignore lint/style/noNonNullAssertion: Test file - configDir is set in createTestProfile expect(existsSync(profile.configDir!)).toBe(true); }); @@ -224,7 +223,6 @@ describe('Claude Profile IPC Integration', () => { await handleProfileSave?.(null, profile); - // biome-ignore lint/style/noNonNullAssertion: Test file - configDir is set in createTestProfile expect(existsSync(profile.configDir!)).toBe(false); }); diff --git a/apps/desktop/src/main/ai/config/__tests__/phase-config.test.ts b/apps/desktop/src/main/ai/config/__tests__/phase-config.test.ts index 1989e834bd..c52c8d8448 100644 --- a/apps/desktop/src/main/ai/config/__tests__/phase-config.test.ts +++ b/apps/desktop/src/main/ai/config/__tests__/phase-config.test.ts @@ -89,6 +89,10 @@ describe('resolveModelId', () => { beforeEach(() => { process.env = { ...originalEnv }; + // Clear model override env vars to ensure consistent test results + delete process.env.ANTHROPIC_DEFAULT_OPUS_MODEL; + delete process.env.ANTHROPIC_DEFAULT_SONNET_MODEL; + delete process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL; }); afterEach(() => { diff --git a/apps/desktop/src/main/changelog/changelog-service.ts b/apps/desktop/src/main/changelog/changelog-service.ts index 0ba31698e6..5a2b4782dd 100644 --- a/apps/desktop/src/main/changelog/changelog-service.ts +++ b/apps/desktop/src/main/changelog/changelog-service.ts @@ -499,7 +499,6 @@ export class ChangelogService extends EventEmitter { this.debug('Error in AI version suggestion, falling back to patch bump', error); // Fallback to patch bump if AI fails // currentVersion is guaranteed non-empty: the try block returns early if falsy or invalid - // biome-ignore lint/style/noNonNullAssertion: guarded by early returns in try block const [major, minor, patch] = currentVersion!.split('.').map(Number); return { version: `${major}.${minor}.${patch + 1}`, diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 2ae1cc8c1b..9feda4e188 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -385,6 +385,16 @@ function createWindow(): void { agentManager?.killAll?.()?.catch((err: unknown) => { console.warn('[main] Error killing agents on window close:', err); }); + // Clear GitLab MR polling intervals for all projects + import('./ipc-handlers/gitlab/mr-review-handlers').then(({ clearPollingForProject }) => { + const { projectStore } = require('./project-store'); + const projects = projectStore.getProjects(); + for (const project of projects) { + clearPollingForProject(project.id); + } + }).catch((err: unknown) => { + console.warn('[main] Error clearing GitLab polling on window close:', err); + }); mainWindow = null; }); } diff --git a/apps/desktop/src/main/ipc-handlers/gitlab/autofix-handlers.ts b/apps/desktop/src/main/ipc-handlers/gitlab/autofix-handlers.ts index 87b8edf00e..4b5c77b0a5 100644 --- a/apps/desktop/src/main/ipc-handlers/gitlab/autofix-handlers.ts +++ b/apps/desktop/src/main/ipc-handlers/gitlab/autofix-handlers.ts @@ -304,9 +304,10 @@ function sendProgress( function sendError( mainWindow: BrowserWindow, projectId: string, + issueIid: number, error: string ): void { - mainWindow.webContents.send(IPC_CHANNELS.GITLAB_AUTOFIX_ERROR, projectId, error); + mainWindow.webContents.send(IPC_CHANNELS.GITLAB_AUTOFIX_ERROR, projectId, issueIid, error); } /** @@ -498,7 +499,7 @@ export function registerAutoFixHandlers( }); } catch (error) { debugLog('Auto-fix failed', { issueIid, error: error instanceof Error ? error.message : error }); - sendError(mainWindow, projectId, error instanceof Error ? error.message : 'Failed to start auto-fix'); + sendError(mainWindow, projectId, issueIid, error instanceof Error ? error.message : 'Failed to start auto-fix'); } } ); diff --git a/apps/desktop/src/main/ipc-handlers/gitlab/mr-review-handlers.ts b/apps/desktop/src/main/ipc-handlers/gitlab/mr-review-handlers.ts index 8ade48cd05..e7e5f1aafd 100644 --- a/apps/desktop/src/main/ipc-handlers/gitlab/mr-review-handlers.ts +++ b/apps/desktop/src/main/ipc-handlers/gitlab/mr-review-handlers.ts @@ -10,15 +10,14 @@ * 6. Approve MR */ -import { ipcMain } from 'electron'; -import type { BrowserWindow } from 'electron'; +import { ipcMain, BrowserWindow } from 'electron'; import path from 'path'; import fs from 'fs'; import { randomUUID } from 'crypto'; import { IPC_CHANNELS, MODEL_ID_MAP, DEFAULT_FEATURE_MODELS, DEFAULT_FEATURE_THINKING } from '../../../shared/constants'; import { getGitLabConfig, gitlabFetch, encodeProjectPath } from './utils'; import { readSettingsFile } from '../../settings-utils'; -import type { Project, AppSettings } from '../../../shared/types'; +import type { Project, AppSettings, IPCResult, GitLabMergeRequest } from '../../../shared/types'; import type { MRReviewResult, MRReviewProgress, @@ -26,6 +25,7 @@ import type { } from './types'; import { createContextLogger } from '../github/utils/logger'; import { withProjectOrNull } from '../github/utils/project-middleware'; +import { projectStore } from '../../project-store'; import { createIPCCommunicators } from '../github/utils/ipc-communicator'; import { MRReviewEngine, @@ -47,6 +47,27 @@ const REBASE_POLL_INTERVAL_MS = 1000; // Default rebase timeout (60 seconds). Can be overridden via GITLAB_REBASE_TIMEOUT_MS env var const REBASE_TIMEOUT_MS = parseInt(process.env.GITLAB_REBASE_TIMEOUT_MS || '60000', 10); +/** + * Registry of status polling intervals for MR updates + * Key format: `${projectId}:${mrIid}` + */ +const statusPollingIntervals = new Map(); +const pollingInProgress = new Set(); + +/** + * Clear all polling intervals for a specific project + * Called when a project is deleted to prevent memory leaks + */ +function clearPollingForProject(projectId: string): void { + const keysToDelete = Array.from(statusPollingIntervals.keys()) + .filter(key => key.startsWith(`${projectId}:`)); + keysToDelete.forEach(key => { + clearInterval(statusPollingIntervals.get(key)!); + statusPollingIntervals.delete(key); + pollingInProgress.delete(key); + }); +} + /** * Get the registry key for an MR review */ @@ -790,6 +811,7 @@ export function registerMRReviewHandlers( } const reviewedCommitSha = review.reviewedCommitSha || (review as any).reviewed_commit_sha; + const reviewedAt = review.reviewedAt || (review as any).reviewed_at; if (!reviewedCommitSha) { debugLog('No reviewedCommitSha in review', { mrIid }); return { hasNewCommits: false }; @@ -813,27 +835,40 @@ export function registerMRReviewHandlers( if (reviewedCommitSha === currentHeadSha) { return { hasNewCommits: false, + hasCommitsAfterPosting: false, currentSha: currentHeadSha, reviewedSha: reviewedCommitSha, }; } - // Get commits to count new ones + // Get commits to count new ones and check for commits after review posting const commits = await gitlabFetch( config.token, config.instanceUrl, `/projects/${encodedProject}/merge_requests/${mrIid}/commits` - ) as Array<{ id: string }>; + ) as Array<{ id: string; created_at: string }>; // Find how many commits are after the reviewed one let newCommitCount = 0; + // Check if any commits were added AFTER the review was posted + let hasCommitsAfterPosting = false; + const reviewTime = reviewedAt ? new Date(reviewedAt).getTime() : 0; + for (const commit of commits) { if (commit.id === reviewedCommitSha) break; newCommitCount++; + // If commit was created after the review was posted, it's a true follow-up commit + if (reviewTime > 0 && commit.created_at) { + const commitTime = new Date(commit.created_at).getTime(); + if (commitTime > reviewTime) { + hasCommitsAfterPosting = true; + } + } } return { hasNewCommits: true, + hasCommitsAfterPosting, currentSha: currentHeadSha, reviewedSha: reviewedCommitSha, newCommitCount: newCommitCount || 1, @@ -973,4 +1008,431 @@ export function registerMRReviewHandlers( ); debugLog('MR review handlers registered'); + + // ============================================ + // NEW: Additional handlers for feature parity + // ============================================ + + /** + * Delete a posted MR review (note) + */ + ipcMain.handle( + IPC_CHANNELS.GITLAB_MR_DELETE_REVIEW, + async (_event, projectId: string, mrIid: number, noteId: number): Promise> => { + debugLog('deleteReview handler called', { projectId, mrIid, noteId }); + + const result = await withProjectOrNull(projectId, async (project) => { + const config = await getGitLabConfig(project); + if (!config) return { success: false, error: 'GitLab not configured' }; + + const { token, instanceUrl } = config; + const encodedProject = encodeProjectPath(config.project); + + try { + await gitlabFetch( + token, + instanceUrl, + `/projects/${encodedProject}/merge_requests/${mrIid}/notes/${noteId}`, + { method: 'DELETE' } + ); + + // Clear the posted findings cache since the review note was deleted + const reviewPath = path.join(getGitLabDir(project), 'mr', `review_${mrIid}.json`); + const tempPath = `${reviewPath}.tmp.${randomUUID()}`; + try { + const data = JSON.parse(fs.readFileSync(reviewPath, 'utf-8')); + data.has_posted_findings = false; + data.posted_finding_ids = []; + // Write to temp file first, then rename atomically + fs.writeFileSync(tempPath, JSON.stringify(data, null, 2), 'utf-8'); + fs.renameSync(tempPath, reviewPath); + debugLog('Cleared posted findings cache after note deletion', { mrIid, noteId }); + } catch (cacheError) { + // Clean up temp file if it exists + try { fs.unlinkSync(tempPath); } catch { /* ignore cleanup errors */ } + debugLog('Failed to clear posted findings cache', { error: cacheError instanceof Error ? cacheError.message : cacheError }); + // Continue anyway - the deletion succeeded even if cache update failed + } + + return { success: true, data: { deleted: true } }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + debugLog('Failed to delete review note', { mrIid, noteId, error: errorMessage }); + return { success: false, error: errorMessage }; + } + }); + + if (result === null) { + return { success: false, error: 'Project not found' }; + } + return result; + } + ); + + /** + * Check MR merge readiness + */ + ipcMain.handle( + IPC_CHANNELS.GITLAB_MR_CHECK_MERGE_READINESS, + async (_event, projectId: string, mrIid: number): Promise> => { + debugLog('checkMergeReadiness handler called', { projectId, mrIid }); + + const result = await withProjectOrNull(projectId, async (project) => { + const config = await getGitLabConfig(project); + if (!config) return { success: false, error: 'GitLab not configured' }; + + const { token, instanceUrl } = config; + const encodedProject = encodeProjectPath(config.project); + + try { + const mrData = await gitlabFetch( + token, + instanceUrl, + `/projects/${encodedProject}/merge_requests/${mrIid}` + ) as { + merge_status?: string; + detailed_merge_status?: string; + has_conflicts?: boolean; + blocking_discussions_resolved?: boolean; + pipeline?: { status?: string }; + }; + + const detailedStatus = mrData.detailed_merge_status || mrData.merge_status || 'cannot_be_merged'; + const canMerge = detailedStatus === 'can_be_merged' || detailedStatus === 'mergeable'; + const hasConflicts = mrData.has_conflicts || false; + const needsDiscussion = detailedStatus === 'discussions_not_resolved' || mrData.blocking_discussions_resolved === false; + const pipelineStatus = mrData.pipeline?.status; + + return { + success: true, + data: { + canMerge, + hasConflicts, + needsDiscussion, + pipelineStatus + } + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + debugLog('Failed to check merge readiness', { mrIid, error: errorMessage }); + return { success: false, error: errorMessage }; + } + }); + + if (result === null) { + return { success: false, error: 'Project not found' }; + } + return result; + } + ); + + /** + * Get AI review logs for an MR + * TODO: Return structured PRLogs type instead of string[] to match MRLogs component expectations + */ + ipcMain.handle( + IPC_CHANNELS.GITLAB_MR_GET_LOGS, + async (_event, projectId: string, mrIid: number): Promise> => { + debugLog('getLogs handler called', { projectId, mrIid }); + + const result = await withProjectOrNull(projectId, async (project) => { + const reviewPath = path.join(getGitLabDir(project), 'mr', `review_${mrIid}.json`); + const logsPath = path.join(getGitLabDir(project), 'mr', `logs_${mrIid}.json`); + + try { + let logs: string[] = []; + + if (fs.existsSync(logsPath)) { + const logsData = JSON.parse(fs.readFileSync(logsPath, 'utf-8')); + logs = logsData.entries || []; + } else if (fs.existsSync(reviewPath)) { + // If no separate log file, return basic info from review + const reviewData = JSON.parse(fs.readFileSync(reviewPath, 'utf-8')); + logs = [`Review completed at ${reviewData.reviewed_at || 'unknown'}`]; + } + + return { success: true, data: logs }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + debugLog('Failed to get logs', { mrIid, error: errorMessage }); + return { success: false, error: errorMessage }; + } + }); + + if (result === null) { + return { success: false, error: 'Project not found' }; + } + return result; + } + ); + + /** + * Start status polling for MR updates + */ + ipcMain.handle( + IPC_CHANNELS.GITLAB_MR_STATUS_POLL_START, + async (event, projectId: string, mrIid: number, intervalMs: number = 5000): Promise> => { + debugLog('statusPollStart handler called', { projectId, mrIid, intervalMs }); + + const result = await withProjectOrNull(projectId, async (_project) => { + const pollKey = `${projectId}:${mrIid}`; + + // Clear existing interval if any + if (statusPollingIntervals.has(pollKey)) { + clearInterval(statusPollingIntervals.get(pollKey)!); + } + + // Get the window that initiated this polling - more robust than getAllWindows()[0] + const callingWindow = BrowserWindow.fromWebContents(event.sender); + if (!callingWindow) { + return { success: false, error: 'Could not identify calling window' }; + } + + // Clamp interval to safe range (1s to 60s) + const safeIntervalMs = Number.isFinite(intervalMs) + ? Math.min(60_000, Math.max(1_000, intervalMs)) + : 5_000; + + // Start new polling interval + const interval = setInterval(async () => { + const pollKey = `${projectId}:${mrIid}`; + + // Stop polling if window is destroyed + if (!callingWindow || callingWindow.isDestroyed()) { + clearInterval(interval); + statusPollingIntervals.delete(pollKey); + pollingInProgress.delete(pollKey); + return; + } + + // Prevent concurrent polls + if (pollingInProgress.has(pollKey)) { + return; + } + + pollingInProgress.add(pollKey); + + try { + // Fetch current project to avoid stale config from closure + const currentProject = projectStore.getProject(projectId); + if (!currentProject) { + debugLog('Project not found during poll - stopping poller', { projectId }); + clearInterval(interval); + statusPollingIntervals.delete(pollKey); + pollingInProgress.delete(pollKey); + return; + } + + // Emit status update to renderer + if (callingWindow && !callingWindow.isDestroyed()) { + + const config = await getGitLabConfig(currentProject); + if (!config) return; + + const { token, instanceUrl } = config; + const encodedProject = encodeProjectPath(config.project); + + const mrData = await gitlabFetch( + token, + instanceUrl, + `/projects/${encodedProject}/merge_requests/${mrIid}` + ) as { + state?: string; + merge_status?: string; + updated_at?: string; + }; + + callingWindow.webContents.send(IPC_CHANNELS.GITLAB_MR_STATUS_UPDATE, { + projectId, + mrIid, + state: mrData.state, + mergeStatus: mrData.merge_status, + updatedAt: mrData.updated_at + }); + } + } catch (error) { + debugLog('Status poll error', { mrIid, error }); + } finally { + pollingInProgress.delete(pollKey); + } + }, safeIntervalMs); + + statusPollingIntervals.set(pollKey, interval); + + return { success: true, data: { polling: true } }; + }); + + if (result === null) { + return { success: false, error: 'Project not found' }; + } + return result; + } + ); + + ipcMain.handle( + IPC_CHANNELS.GITLAB_MR_STATUS_POLL_STOP, + async (_event, projectId: string, mrIid: number): Promise> => { + debugLog('statusPollStop handler called', { projectId, mrIid }); + + const pollKey = `${projectId}:${mrIid}`; + + if (statusPollingIntervals.has(pollKey)) { + clearInterval(statusPollingIntervals.get(pollKey)!); + statusPollingIntervals.delete(pollKey); + pollingInProgress.delete(pollKey); + return { success: true, data: { stopped: true } }; + } + + return { success: true, data: { stopped: false } }; + } + ); + + /** + * Get MR review memories + */ + ipcMain.handle( + IPC_CHANNELS.GITLAB_MR_MEMORY_GET, + async (_event, projectId: string, mrIid: number): Promise> => { + debugLog('memoryGet handler called', { projectId, mrIid }); + + // TODO: Implement memory retrieval from Graphiti memory system + return { + success: false, + error: 'Memory feature not yet implemented', + code: 'NOT_IMPLEMENTED' + } as IPCResult; + } + ); + + /** + * Search MR review memories + */ + ipcMain.handle( + IPC_CHANNELS.GITLAB_MR_MEMORY_SEARCH, + async (_event, projectId: string, query: string): Promise> => { + debugLog('memorySearch handler called', { projectId, query }); + + // TODO: Implement memory search from Graphiti memory system + return { + success: false, + error: 'Memory feature not yet implemented', + code: 'NOT_IMPLEMENTED' + } as IPCResult; + } + ); + + /** + * Auto-fix issues in MR + */ + ipcMain.handle( + IPC_CHANNELS.GITLAB_MR_FIX, + async (_event, projectId: string, mrIid: number, findings: string[]): Promise> => { + debugLog('autoFix handler called', { projectId, mrIid, findingsCount: findings.length }); + + // TODO: Implement auto-fix functionality + // This will integrate with the existing autofix handlers + return { + success: false, + error: 'Auto-fix not yet implemented - use GITLAB_AUTOFIX_START instead' + }; + } + ); + + /** + * Batch load reviews for multiple MRs + */ + ipcMain.handle( + IPC_CHANNELS.GITLAB_MR_GET_REVIEWS_BATCH, + async (_event, projectId: string, mrIids: number[]): Promise>> => { + debugLog('getReviewsBatch handler called', { projectId, mrIids }); + + const result = await withProjectOrNull(projectId, async (project) => { + const results: Record = {}; + + for (const mrIid of mrIids) { + const review = getReviewResult(project, mrIid); + results[mrIid] = review; + } + + return { success: true, data: results }; + }); + + if (result === null) { + return { success: false, error: 'Project not found' }; + } + return result; + } + ); + + /** + * Load more MRs (pagination) + */ + ipcMain.handle( + IPC_CHANNELS.GITLAB_MR_LIST_MORE, + async ( + _event, + projectId: string, + state?: 'opened' | 'closed' | 'merged' | 'all', + page: number = 2 + ): Promise> => { + debugLog('listMore handler called', { projectId, state, page }); + + const result = await withProjectOrNull(projectId, async (project) => { + const config = await getGitLabConfig(project); + if (!config) { + return { success: false, error: 'GitLab not configured' }; + } + + const { token, instanceUrl } = config; + const encodedProject = encodeProjectPath(config.project); + + try { + const stateParam = state === 'all' ? undefined : state; + // Over-fetch by 1 to reliably determine if there are more pages + const queryParams = new URLSearchParams({ + per_page: '21', + page: String(page), + ...(stateParam && { state: stateParam }) + }); + + const mrs = await gitlabFetch( + token, + instanceUrl, + `/projects/${encodedProject}/merge_requests?${queryParams.toString()}` + ) as GitLabMergeRequest[]; + + // If we got 21 items, there's definitely more. Otherwise we're at the end. + const hasMore = mrs.length > 20; + // Only return the requested page size (20 items) + const returnMrs = hasMore ? mrs.slice(0, 20) : mrs; + + return { + success: true, + data: { + mrs: returnMrs, + hasMore + } + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + debugLog('Failed to load more MRs', { projectId, page, error: errorMessage }); + return { success: false, error: errorMessage }; + } + }); + + if (result === null) { + return { success: false, error: 'Project not found' }; + } + return result; + } + ); + + debugLog('Additional MR handlers registered'); } + +export { clearPollingForProject }; diff --git a/apps/desktop/src/main/ipc-handlers/memory-handlers.ts b/apps/desktop/src/main/ipc-handlers/memory-handlers.ts index ec74869987..72900c5771 100644 --- a/apps/desktop/src/main/ipc-handlers/memory-handlers.ts +++ b/apps/desktop/src/main/ipc-handlers/memory-handlers.ts @@ -12,8 +12,12 @@ import { getOllamaExecutablePaths, getOllamaInstallCommand as getPlatformOllamaI import { IPC_CHANNELS } from '../../shared/constants'; import type { IPCResult, + InfrastructureStatus, + MemoryValidationResult, } from '../../shared/types'; import { openTerminalWithCommand } from './claude-code-handlers'; +import { getEmbeddingProvider } from './context/memory-service-factory'; +const { join } = path; /** * Ollama Service Status @@ -547,6 +551,154 @@ export function registerMemoryHandlers(): void { } ); + // ============================================ + // Memory Infrastructure Handlers (LadybugDB - libSQL) + // ============================================ + + /** + * Get memory infrastructure status. + * Checks if the local libSQL database is available and returns status. + * + * @async + * @param {string} [dbPath] - Optional custom database path (for testing) + * @returns {Promise>} Status with memory database info + */ + ipcMain.handle( + IPC_CHANNELS.INFRASTRUCTURE_GET_STATUS, + async (_, dbPath?: string): Promise> => { + try { + const { getMemoryClient } = await import('../ai/memory/db'); + const { existsSync } = await import('fs'); + + // Get default memory.db path from userData + const { app } = await import('electron'); + const defaultPath = join(app.getPath('userData'), 'memory.db'); + const checkPath = dbPath || defaultPath; + + // Check if database file exists + const dbExists = existsSync(checkPath); + + // Try to initialize the client to verify it's functional + await getMemoryClient(); + const embeddingProvider = getEmbeddingProvider(); + + return { + success: true, + data: { + memory: { + kuzuInstalled: false, // Kuzu is no longer used, libSQL only + databasePath: checkPath, + databaseExists: dbExists, + databases: dbExists ? ['memory'] : [], + error: dbExists ? undefined : 'Database file not found', + }, + ready: dbExists && embeddingProvider !== null, + }, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to get infrastructure status', + }; + } + } + ); + + /** + * List available memory databases. + * Returns a list of database names available in the system. + * + * @async + * @param {string} [dbPath] - Optional custom database path (for testing) + * @returns {Promise>} Array of database names + */ + ipcMain.handle( + IPC_CHANNELS.INFRASTRUCTURE_LIST_DATABASES, + async (_, dbPath?: string): Promise> => { + try { + const { existsSync } = await import('fs'); + const { app } = await import('electron'); + const { join } = await import('path'); + + const defaultPath = join(app.getPath('userData'), 'memory.db'); + const checkPath = dbPath || defaultPath; + + const databases: string[] = []; + if (existsSync(checkPath)) { + databases.push('memory'); + } + + return { success: true, data: databases }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to list databases', + }; + } + } + ); + + /** + * Test memory database connection. + * Verifies that a specific database can be connected to and queried. + * + * @async + * @param {string} [dbPath] - Optional custom database path + * @param {string} [database] - Database name to test (default: 'memory') + * @returns {Promise>} Connection test result + */ + ipcMain.handle( + IPC_CHANNELS.INFRASTRUCTURE_TEST_CONNECTION, + async (_, dbPath?: string, database?: string): Promise> => { + try { + const { getMemoryClient } = await import('../ai/memory/db'); + const { existsSync } = await import('fs'); + const { app } = await import('electron'); + const { join } = await import('path'); + + const defaultPath = join(app.getPath('userData'), 'memory.db'); + const checkPath = dbPath || defaultPath; + const dbName = database || 'memory'; + + // Check if database file exists + if (!existsSync(checkPath)) { + return { + success: true, + data: { + success: false, + message: `Database '${dbName}' not found at ${checkPath}`, + }, + }; + } + + // Try to connect and run a simple query + const client = await getMemoryClient(); + await client.execute('SELECT 1'); + + const embeddingProvider = getEmbeddingProvider(); + + return { + success: true, + data: { + success: true, + message: `Successfully connected to ${dbName} database`, + details: { + provider: embeddingProvider || 'none', + }, + }, + }; + } catch (error) { + return { + success: true, // Return success with failure details in data + data: { + success: false, + message: error instanceof Error ? error.message : 'Connection test failed', + }, + }; + } + } + ); + // ============================================ // Memory System (libSQL-backed) Handlers // ============================================ diff --git a/apps/desktop/src/main/ipc-handlers/project-handlers.ts b/apps/desktop/src/main/ipc-handlers/project-handlers.ts index e5567c1792..248ba346c5 100644 --- a/apps/desktop/src/main/ipc-handlers/project-handlers.ts +++ b/apps/desktop/src/main/ipc-handlers/project-handlers.ts @@ -266,6 +266,9 @@ export function registerProjectHandlers( ipcMain.handle( IPC_CHANNELS.PROJECT_REMOVE, async (_, projectId: string): Promise => { + // Clear GitLab MR polling for this project to prevent memory leaks + const { clearPollingForProject } = await import('./gitlab/mr-review-handlers'); + clearPollingForProject(projectId); const success = projectStore.removeProject(projectId); return { success }; } diff --git a/apps/desktop/src/main/ipc-handlers/terminal-handlers.ts b/apps/desktop/src/main/ipc-handlers/terminal-handlers.ts index e1cb0d3fae..b5b14c44ca 100644 --- a/apps/desktop/src/main/ipc-handlers/terminal-handlers.ts +++ b/apps/desktop/src/main/ipc-handlers/terminal-handlers.ts @@ -12,6 +12,7 @@ import { debugLog, } from '../../shared/utils/debug-logger'; import { migrateSession } from '../claude-profile/session-utils'; import { createProfileDirectory } from '../claude-profile/profile-utils'; import { isValidConfigDir } from '../utils/config-path-validator'; +import { sessionPersistence } from '../terminal/session-persistence'; /** @@ -115,6 +116,22 @@ export function registerTerminalHandlers( } ); + // Save terminal buffer (serialized xterm.js content) + ipcMain.handle( + IPC_CHANNELS.TERMINAL_SAVE_BUFFER, + async (_, terminalId: string, serializedBuffer: string): Promise => { + try { + sessionPersistence.saveBuffer(terminalId, serializedBuffer); + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to save terminal buffer', + }; + } + } + ); + // Claude profile management (multi-account support) ipcMain.handle( IPC_CHANNELS.CLAUDE_PROFILES_GET, diff --git a/apps/desktop/src/main/terminal/session-persistence.ts b/apps/desktop/src/main/terminal/session-persistence.ts index 3fad68a7f4..d7ca07be0e 100644 --- a/apps/desktop/src/main/terminal/session-persistence.ts +++ b/apps/desktop/src/main/terminal/session-persistence.ts @@ -14,8 +14,23 @@ import type { TerminalRecoveryInfo, } from '../../shared/types/terminal-session'; -const SESSIONS_FILE = path.join(app.getPath('userData'), 'terminal-sessions.json'); -const BUFFERS_DIR = path.join(app.getPath('userData'), 'terminal-buffers'); +// Helper to get app data path safely (works in tests where app might not be fully mocked) +function getUserDataPath(): string { + try { + if (app && typeof app.getPath === 'function') { + return app.getPath('userData'); + } + } catch { + // Ignore errors in test environment + } + // Fallback for test environment - use home directory instead of /tmp for security + const os = require('os'); + const path = require('path'); + return path.join(os.homedir(), '.aperant-test-data'); +} + +const SESSIONS_FILE = path.join(getUserDataPath(), 'terminal-sessions.json'); +const BUFFERS_DIR = path.join(getUserDataPath(), 'terminal-buffers'); // Session age limit: 7 days const MAX_SESSION_AGE_MS = 7 * 24 * 60 * 60 * 1000; @@ -307,19 +322,31 @@ class SessionPersistence { // Singleton instance export const sessionPersistence = new SessionPersistence(); -// Hook into app lifecycle -app.on('before-quit', () => { - console.warn('[SessionPersistence] App quitting, saving sessions...'); - sessionPersistence.saveNow(); -}); - -app.on('will-quit', () => { - sessionPersistence.saveNow(); -}); +// Hook into app lifecycle (only if running in real Electron environment, not tests) +try { + if (app && typeof app.on === 'function') { + app.on('before-quit', () => { + console.warn('[SessionPersistence] App quitting, saving sessions...'); + sessionPersistence.saveNow(); + }); + + app.on('will-quit', () => { + sessionPersistence.saveNow(); + }); + } +} catch { + // Ignore errors in test environment +} // Cleanup orphaned buffers on startup (after initial load) -app.whenReady().then(() => { - setTimeout(() => { - sessionPersistence.cleanupOrphanedBuffers(); - }, 5000); // Wait 5 seconds after app ready -}); +try { + if (app && typeof app.whenReady === 'function') { + app.whenReady().then(() => { + setTimeout(() => { + sessionPersistence.cleanupOrphanedBuffers(); + }, 5000); // Wait 5 seconds after app ready + }); + } +} catch { + // Ignore errors in test environment +} diff --git a/apps/desktop/src/preload/api/modules/github-api.ts b/apps/desktop/src/preload/api/modules/github-api.ts index c2115eb110..1ac8d38ee5 100644 --- a/apps/desktop/src/preload/api/modules/github-api.ts +++ b/apps/desktop/src/preload/api/modules/github-api.ts @@ -178,10 +178,28 @@ export interface GitHubAPI { /** AI-powered version suggestion based on commits since last release */ suggestReleaseVersion: (projectId: string) => Promise>; + // Release operations (changelog-based) + getReleaseableVersions: (projectId: string) => Promise>; + runReleasePreflightCheck: (projectId: string, version: string) => Promise>; + createRelease: (options: { + projectId: string; + version: string; + body: string; + draft?: boolean; + prerelease?: boolean; + }) => Promise>; + // OAuth operations (gh CLI) checkGitHubCli: () => Promise>; checkGitHubAuth: () => Promise>; - startGitHubAuth: () => Promise>; + startGitHubAuth: () => Promise>; getGitHubToken: () => Promise>; getGitHubUser: () => Promise>; listGitHubUserRepos: () => Promise }>>; @@ -571,6 +589,22 @@ export const createGitHubAPI = (): GitHubAPI => ({ suggestReleaseVersion: (projectId: string): Promise> => invokeIpc(IPC_CHANNELS.RELEASE_SUGGEST_VERSION, projectId), + // Release operations (changelog-based) + getReleaseableVersions: (projectId: string): Promise> => + invokeIpc(IPC_CHANNELS.RELEASE_GET_VERSIONS, projectId), + + runReleasePreflightCheck: (projectId: string, version: string): Promise> => + invokeIpc(IPC_CHANNELS.RELEASE_PREFLIGHT, projectId, version), + + createRelease: (options: { + projectId: string; + version: string; + body: string; + draft?: boolean; + prerelease?: boolean; + }): Promise> => + invokeIpc(IPC_CHANNELS.RELEASE_CREATE, options), + // OAuth operations (gh CLI) checkGitHubCli: (): Promise> => invokeIpc(IPC_CHANNELS.GITHUB_CHECK_CLI), @@ -578,7 +612,14 @@ export const createGitHubAPI = (): GitHubAPI => ({ checkGitHubAuth: (): Promise> => invokeIpc(IPC_CHANNELS.GITHUB_CHECK_AUTH), - startGitHubAuth: (): Promise> => + startGitHubAuth: (): Promise> => invokeIpc(IPC_CHANNELS.GITHUB_START_AUTH), getGitHubToken: (): Promise> => diff --git a/apps/desktop/src/preload/api/modules/gitlab-api.ts b/apps/desktop/src/preload/api/modules/gitlab-api.ts index ad3f58b833..d36912be95 100644 --- a/apps/desktop/src/preload/api/modules/gitlab-api.ts +++ b/apps/desktop/src/preload/api/modules/gitlab-api.ts @@ -18,6 +18,7 @@ import type { GitLabAnalyzePreviewResult, GitLabTriageConfig, GitLabTriageResult, + GitLabMRStatusUpdate, GitLabGroup, IPCResult } from '../../../shared/types'; @@ -79,6 +80,30 @@ export interface GitLabAPI { cancelGitLabMRReview: (projectId: string, mrIid: number) => Promise; checkGitLabMRNewCommits: (projectId: string, mrIid: number) => Promise; + // NEW: Additional MR operations for feature parity + listMoreGitLabMRs: ( + projectId: string, + state?: 'opened' | 'closed' | 'merged' | 'all', + page?: number + ) => Promise>; + getGitLabMRReviewsBatch: (projectId: string, mrIids: number[]) => Promise>>; + deleteGitLabMRReview: (projectId: string, mrIid: number, noteId: number) => Promise>; + fixGitLabMR: (projectId: string, mrIid: number, findings: string[]) => Promise>; + startGitLabMRStatusPoll: (projectId: string, mrIid: number, intervalMs?: number) => Promise>; + stopGitLabMRStatusPoll: (projectId: string, mrIid: number) => Promise>; + getGitLabMRLogs: (projectId: string, mrIid: number) => Promise>; + getGitLabMRMemory: (projectId: string, mrIid: number) => Promise>; + searchGitLabMRMemory: (projectId: string, query: string) => Promise>; + checkGitLabMRMergeReadiness: ( + projectId: string, + mrIid: number + ) => Promise>; + // MR Review Event Listeners onGitLabMRReviewProgress: ( callback: (projectId: string, progress: GitLabMRReviewProgress) => void @@ -89,6 +114,9 @@ export interface GitLabAPI { onGitLabMRReviewError: ( callback: (projectId: string, data: { mrIid: number; error: string }) => void ) => IpcListenerCleanup; + onGitLabMRStatusUpdate: ( + callback: (update: GitLabMRStatusUpdate) => void + ) => IpcListenerCleanup; // GitLab Auto-Fix operations getGitLabAutoFixConfig: (projectId: string) => Promise; @@ -109,7 +137,7 @@ export interface GitLabAPI { callback: (projectId: string, result: GitLabAutoFixQueueItem) => void ) => IpcListenerCleanup; onGitLabAutoFixError: ( - callback: (projectId: string, error: string) => void + callback: (projectId: string, issueIid: number, error: string) => void ) => IpcListenerCleanup; onGitLabAutoFixAnalyzePreviewProgress: ( callback: (projectId: string, progress: { phase: string; progress: number; message: string }) => void @@ -278,6 +306,56 @@ export const createGitLabAPI = (): GitLabAPI => ({ checkGitLabMRNewCommits: (projectId: string, mrIid: number): Promise => invokeIpc(IPC_CHANNELS.GITLAB_MR_CHECK_NEW_COMMITS, projectId, mrIid), + // NEW: Additional MR operations for feature parity + listMoreGitLabMRs: ( + projectId: string, + state?: 'opened' | 'closed' | 'merged' | 'all', + page?: number + ): Promise> => + invokeIpc(IPC_CHANNELS.GITLAB_MR_LIST_MORE, projectId, state, page), + + getGitLabMRReviewsBatch: ( + projectId: string, + mrIids: number[] + ): Promise>> => + invokeIpc(IPC_CHANNELS.GITLAB_MR_GET_REVIEWS_BATCH, projectId, mrIids), + + deleteGitLabMRReview: (projectId: string, mrIid: number, noteId: number): Promise> => + invokeIpc(IPC_CHANNELS.GITLAB_MR_DELETE_REVIEW, projectId, mrIid, noteId), + + fixGitLabMR: (projectId: string, mrIid: number, findings: string[]): Promise> => + invokeIpc(IPC_CHANNELS.GITLAB_MR_FIX, projectId, mrIid, findings), + + startGitLabMRStatusPoll: ( + projectId: string, + mrIid: number, + intervalMs?: number + ): Promise> => + invokeIpc(IPC_CHANNELS.GITLAB_MR_STATUS_POLL_START, projectId, mrIid, intervalMs), + + stopGitLabMRStatusPoll: (projectId: string, mrIid: number): Promise> => + invokeIpc(IPC_CHANNELS.GITLAB_MR_STATUS_POLL_STOP, projectId, mrIid), + + getGitLabMRLogs: (projectId: string, mrIid: number): Promise> => + invokeIpc(IPC_CHANNELS.GITLAB_MR_GET_LOGS, projectId, mrIid), + + getGitLabMRMemory: (projectId: string, mrIid: number): Promise> => + invokeIpc(IPC_CHANNELS.GITLAB_MR_MEMORY_GET, projectId, mrIid), + + searchGitLabMRMemory: (projectId: string, query: string): Promise> => + invokeIpc(IPC_CHANNELS.GITLAB_MR_MEMORY_SEARCH, projectId, query), + + checkGitLabMRMergeReadiness: ( + projectId: string, + mrIid: number + ): Promise> => + invokeIpc(IPC_CHANNELS.GITLAB_MR_CHECK_MERGE_READINESS, projectId, mrIid), + // MR Review Event Listeners onGitLabMRReviewProgress: ( callback: (projectId: string, progress: GitLabMRReviewProgress) => void @@ -294,6 +372,11 @@ export const createGitLabAPI = (): GitLabAPI => ({ ): IpcListenerCleanup => createIpcListener(IPC_CHANNELS.GITLAB_MR_REVIEW_ERROR, callback), + onGitLabMRStatusUpdate: ( + callback: (update: GitLabMRStatusUpdate) => void + ): IpcListenerCleanup => + createIpcListener(IPC_CHANNELS.GITLAB_MR_STATUS_UPDATE, callback), + // GitLab Auto-Fix operations getGitLabAutoFixConfig: (projectId: string): Promise => invokeIpc(IPC_CHANNELS.GITLAB_AUTOFIX_GET_CONFIG, projectId), @@ -334,7 +417,7 @@ export const createGitLabAPI = (): GitLabAPI => ({ createIpcListener(IPC_CHANNELS.GITLAB_AUTOFIX_COMPLETE, callback), onGitLabAutoFixError: ( - callback: (projectId: string, error: string) => void + callback: (projectId: string, issueIid: number, error: string) => void ): IpcListenerCleanup => createIpcListener(IPC_CHANNELS.GITLAB_AUTOFIX_ERROR, callback), diff --git a/apps/desktop/src/preload/api/modules/index.ts b/apps/desktop/src/preload/api/modules/index.ts index e2cc553781..c041bc950b 100644 --- a/apps/desktop/src/preload/api/modules/index.ts +++ b/apps/desktop/src/preload/api/modules/index.ts @@ -11,5 +11,6 @@ export * from './insights-api'; export * from './changelog-api'; export * from './linear-api'; export * from './github-api'; +export * from './gitlab-api'; export * from './shell-api'; export * from './debug-api'; diff --git a/apps/desktop/src/preload/api/project-api.ts b/apps/desktop/src/preload/api/project-api.ts index 818c257d26..e40c141ffd 100644 --- a/apps/desktop/src/preload/api/project-api.ts +++ b/apps/desktop/src/preload/api/project-api.ts @@ -9,7 +9,9 @@ import type { ProjectEnvConfig, GitStatus, KanbanPreferences, - GitBranchDetail + GitBranchDetail, + InfrastructureStatus, + MemoryValidationResult } from '../../shared/types'; // Tab state interface (persisted in main process) @@ -46,6 +48,11 @@ export interface ProjectAPI { searchMemories: (projectId: string, query: string) => Promise>; getRecentMemories: (projectId: string, limit?: number) => Promise>; + // Memory Infrastructure operations (LadybugDB - no Docker required) + getMemoryInfrastructureStatus: (dbPath?: string) => Promise>; + listMemoryDatabases: (dbPath?: string) => Promise>; + testMemoryConnection: (dbPath?: string, database?: string) => Promise>; + // Memory Management verifyMemory: (memoryId: string) => Promise>; pinMemory: (memoryId: string, pinned: boolean) => Promise>; @@ -284,5 +291,15 @@ export const createProjectAPI = (): ProjectAPI => ({ ipcRenderer.invoke(IPC_CHANNELS.OLLAMA_LIST_EMBEDDING_MODELS, baseUrl), pullOllamaModel: (modelName: string, baseUrl?: string) => - ipcRenderer.invoke(IPC_CHANNELS.OLLAMA_PULL_MODEL, modelName, baseUrl) + ipcRenderer.invoke(IPC_CHANNELS.OLLAMA_PULL_MODEL, modelName, baseUrl), + + // Memory Infrastructure operations (LadybugDB - no Docker required) + getMemoryInfrastructureStatus: (dbPath?: string) => + ipcRenderer.invoke(IPC_CHANNELS.INFRASTRUCTURE_GET_STATUS, dbPath), + + listMemoryDatabases: (dbPath?: string) => + ipcRenderer.invoke(IPC_CHANNELS.INFRASTRUCTURE_LIST_DATABASES, dbPath), + + testMemoryConnection: (dbPath?: string, database?: string) => + ipcRenderer.invoke(IPC_CHANNELS.INFRASTRUCTURE_TEST_CONNECTION, dbPath, database) }); diff --git a/apps/desktop/src/preload/api/settings-api.ts b/apps/desktop/src/preload/api/settings-api.ts index 75b826efba..324f4b3b3c 100644 --- a/apps/desktop/src/preload/api/settings-api.ts +++ b/apps/desktop/src/preload/api/settings-api.ts @@ -17,6 +17,7 @@ export interface SettingsAPI { python: ToolDetectionResult; git: ToolDetectionResult; gh: ToolDetectionResult; + glab: ToolDetectionResult; claude: ToolDetectionResult; }>>; @@ -64,6 +65,7 @@ export const createSettingsAPI = (): SettingsAPI => ({ python: ToolDetectionResult; git: ToolDetectionResult; gh: ToolDetectionResult; + glab: ToolDetectionResult; claude: ToolDetectionResult; }>> => ipcRenderer.invoke(IPC_CHANNELS.SETTINGS_GET_CLI_TOOLS_INFO), diff --git a/apps/desktop/src/preload/api/terminal-api.ts b/apps/desktop/src/preload/api/terminal-api.ts index 3cf2557603..4112b06888 100644 --- a/apps/desktop/src/preload/api/terminal-api.ts +++ b/apps/desktop/src/preload/api/terminal-api.ts @@ -72,6 +72,9 @@ export interface TerminalAPI { removeTerminalWorktree: (projectPath: string, name: string, deleteBranch?: boolean) => Promise; listOtherWorktrees: (projectPath: string) => Promise>; + // Terminal Buffer Persistence + saveTerminalBuffer: (terminalId: string, serializedBuffer: string) => Promise; + // Terminal Event Listeners onTerminalOutput: (callback: (id: string, data: string) => void) => () => void; onTerminalExit: (callback: (id: string, exitCode: number) => void) => () => void; @@ -210,6 +213,10 @@ export const createTerminalAPI = (): TerminalAPI => ({ listOtherWorktrees: (projectPath: string): Promise> => ipcRenderer.invoke(IPC_CHANNELS.TERMINAL_WORKTREE_LIST_OTHER, projectPath), + // Terminal Buffer Persistence + saveTerminalBuffer: (terminalId: string, serializedBuffer: string): Promise => + ipcRenderer.invoke(IPC_CHANNELS.TERMINAL_SAVE_BUFFER, terminalId, serializedBuffer), + // Terminal Event Listeners onTerminalOutput: ( callback: (id: string, data: string) => void diff --git a/apps/desktop/src/renderer/components/github-prs/components/StatusIndicator.tsx b/apps/desktop/src/renderer/components/github-prs/components/StatusIndicator.tsx index 1af319f29c..e63aabee0f 100644 --- a/apps/desktop/src/renderer/components/github-prs/components/StatusIndicator.tsx +++ b/apps/desktop/src/renderer/components/github-prs/components/StatusIndicator.tsx @@ -23,7 +23,6 @@ function CIStatusIcon({ status, className }: CIStatusIconProps) { return ; case 'failure': return ; - case 'none': default: return ; } @@ -63,7 +62,6 @@ function ReviewStatusBadge({ status, className }: ReviewStatusBadgeProps) { {t('prStatus.review.pending')} ); - case 'none': default: return null; } @@ -88,7 +86,6 @@ function MergeReadinessIcon({ state, className }: MergeReadinessIconProps) { return ; case 'blocked': return ; - case 'unknown': default: return ; } @@ -169,9 +166,9 @@ export function StatusIndicator({ )} {/* Merge Readiness */} - {showMergeStatus && mergeKey && ( + {showMergeStatus && mergeKey && mergeableState && (
- + {!compact && ( {t(`prStatus.merge.${mergeKey}`)} diff --git a/apps/desktop/src/renderer/components/gitlab-issues/components/AutoFixButton.tsx b/apps/desktop/src/renderer/components/gitlab-issues/components/AutoFixButton.tsx new file mode 100644 index 0000000000..4c6bb64c20 --- /dev/null +++ b/apps/desktop/src/renderer/components/gitlab-issues/components/AutoFixButton.tsx @@ -0,0 +1,153 @@ +/** + * AutoFixButton Component for GitLab Issues + * + * Stub component - implements the same pattern as GitHub's AutoFixButton + * adapted for GitLab issues. + */ + +import { useState, useEffect, useCallback } from 'react'; +import { Wand2, Loader2, AlertCircle, CheckCircle2 } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { Button } from '@/components/ui/button'; +import { Progress } from '@/components/ui/progress'; +import type { GitLabIssue, GitLabAutoFixConfig, GitLabAutoFixProgress, GitLabAutoFixQueueItem } from '@shared/types'; + +interface GitLabAutoFixButtonProps { + issue: GitLabIssue; + projectId: string; + config: GitLabAutoFixConfig | null; + queueItem: GitLabAutoFixQueueItem | null; + onStartAutoFix?: (projectId: string, issueIid: number) => void; +} + +export function GitLabAutoFixButton({ + issue, + projectId, + config, + queueItem, + onStartAutoFix, +}: GitLabAutoFixButtonProps) { + const { t } = useTranslation(['gitlab']); + const [isStarting, setIsStarting] = useState(false); + const [progress, setProgress] = useState(null); + const [error, setError] = useState(null); + const [completed, setCompleted] = useState(false); + + // Check if the issue has an auto-fix label + const hasAutoFixLabel = useCallback(() => { + if (!config || !config.enabled || !config.labels.length) return false; + const issueLabels = issue.labels.map(l => l.toLowerCase()); + return config.labels.some(label => issueLabels.includes(label.toLowerCase())); + }, [config, issue.labels]); + + // Listen for progress events + useEffect(() => { + const cleanupProgress = window.electronAPI.onGitLabAutoFixProgress?.( + (eventProjectId: string, progressData: GitLabAutoFixProgress) => { + if (eventProjectId === projectId && progressData.issueIid === issue.iid) { + setProgress(progressData); + setIsStarting(false); + } + } + ); + + const cleanupComplete = window.electronAPI.onGitLabAutoFixComplete?.( + (eventProjectId: string, result: GitLabAutoFixQueueItem) => { + if (eventProjectId === projectId && result.issueIid === issue.iid) { + setCompleted(true); + setProgress(null); + setIsStarting(false); + } + } + ); + + const cleanupError = window.electronAPI.onGitLabAutoFixError?.( + (eventProjectId: string, issueIid: number, error: string) => { + if (eventProjectId === projectId && issueIid === issue.iid) { + setError(error); + setProgress(null); + setIsStarting(false); + } + } + ); + + return () => { + cleanupProgress?.(); + cleanupComplete?.(); + cleanupError?.(); + }; + }, [projectId, issue.iid]); + + // Check if already in queue + const isInQueue = queueItem && queueItem.status !== 'completed' && queueItem.status !== 'failed'; + const isProcessing = isStarting || progress !== null || isInQueue; + + const handleStartAutoFix = useCallback(() => { + setIsStarting(true); + setError(null); + setCompleted(false); + if (onStartAutoFix) { + onStartAutoFix(projectId, issue.iid); + } else if (window.electronAPI.startGitLabAutoFix) { + window.electronAPI.startGitLabAutoFix(projectId, issue.iid); + } + }, [projectId, issue.iid, onStartAutoFix]); + + // Don't render if auto-fix is disabled or issue doesn't have the right label + if (!config?.enabled) { + return null; + } + + // Show completed state + if (completed || queueItem?.status === 'completed') { + return ( +
+ + {t('gitlab:autoFix.specCreated')} +
+ ); + } + + // Show error state + if (error || queueItem?.status === 'failed') { + return ( +
+
+ + {error || queueItem?.error || t('gitlab:autoFix.autoFixFailed')} +
+ +
+ ); + } + + // Show progress state + if (isProcessing) { + return ( +
+
+ + {progress?.message || t('gitlab:autoFix.processing')} +
+ {progress && ( + + )} +
+ ); + } + + // Show button - either highlighted if has auto-fix label, or normal + return ( + + ); +} diff --git a/apps/desktop/src/renderer/components/gitlab-issues/components/BatchReviewWizard.tsx b/apps/desktop/src/renderer/components/gitlab-issues/components/BatchReviewWizard.tsx new file mode 100644 index 0000000000..f2cc39263c --- /dev/null +++ b/apps/desktop/src/renderer/components/gitlab-issues/components/BatchReviewWizard.tsx @@ -0,0 +1,553 @@ +/** + * BatchReviewWizard Component for GitLab Issues + * + * Stub component - implements the same pattern as GitHub's BatchReviewWizard + * adapted for GitLab issues (using iid instead of issueNumber). + */ + +import { useState, useEffect, useCallback } from 'react'; +import { + Layers, + CheckCircle2, + Loader2, + ChevronDown, + ChevronRight, + Users, + Play, + AlertTriangle, +} from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Progress } from '@/components/ui/progress'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Checkbox } from '@/components/ui/checkbox'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from '@/components/ui/collapsible'; +import type { + GitLabAnalyzePreviewResult, + GitLabAnalyzePreviewProgress, + GitLabProposedBatch, +} from '@shared/types'; + +interface GitLabBatchReviewWizardProps { + isOpen: boolean; + onClose: () => void; + onStartAnalysis: () => void; + onApproveBatches: (batches: GitLabProposedBatch[]) => Promise; + analysisProgress: GitLabAnalyzePreviewProgress | null; + analysisResult: GitLabAnalyzePreviewResult | null; + analysisError: string | null; + isAnalyzing: boolean; + isApproving: boolean; +} + +export function GitLabBatchReviewWizard({ + isOpen, + onClose, + onStartAnalysis, + onApproveBatches, + analysisProgress, + analysisResult, + analysisError, + isAnalyzing, + isApproving, +}: GitLabBatchReviewWizardProps) { + const { t } = useTranslation(['gitlab']); + // Track which batches are selected for approval + const [selectedBatchIds, setSelectedBatchIds] = useState>(new Set()); + // Track which single issues are selected for approval + const [selectedSingleIids, setSelectedSingleIids] = useState>(new Set()); + // Track which batches are expanded + const [expandedBatchIds, setExpandedBatchIds] = useState>(new Set()); + // Current wizard step + const [step, setStep] = useState<'intro' | 'analyzing' | 'review' | 'approving' | 'done'>('intro'); + // Local error state for approval failures + const [approvalError, setApprovalError] = useState(null); + + // Reset state when dialog opens + useEffect(() => { + if (isOpen) { + setSelectedBatchIds(new Set()); + setSelectedSingleIids(new Set()); + setExpandedBatchIds(new Set()); + setStep('intro'); + } + }, [isOpen]); + + // Update step based on analysis state + useEffect(() => { + if (isAnalyzing) { + setStep('analyzing'); + } else if (analysisResult) { + setStep('review'); + // Select all validated batches by default + const validatedIds = new Set( + analysisResult.proposedBatches + .map((b, idx) => b.validated ? idx : -1) + .filter(idx => idx !== -1) + ); + setSelectedBatchIds(validatedIds); + // If no batches, auto-select all single issues + if (analysisResult.proposedBatches.length === 0 && analysisResult.singleIssues.length > 0) { + const singleIssueIids = new Set( + analysisResult.singleIssues.map(issue => issue.iid) + ); + setSelectedSingleIids(singleIssueIids); + } + } else if (analysisError) { + setStep('intro'); + } + }, [isAnalyzing, analysisResult, analysisError]); + + // Update step when approving + useEffect(() => { + if (isApproving) { + setStep('approving'); + } + }, [isApproving]); + + const toggleBatchSelection = useCallback((batchIndex: number) => { + setSelectedBatchIds(prev => { + const next = new Set(prev); + if (next.has(batchIndex)) { + next.delete(batchIndex); + } else { + next.add(batchIndex); + } + return next; + }); + }, []); + + const toggleSingleIssueSelection = useCallback((iid: number) => { + setSelectedSingleIids(prev => { + const next = new Set(prev); + if (next.has(iid)) { + next.delete(iid); + } else { + next.add(iid); + } + return next; + }); + }, []); + + const toggleBatchExpanded = useCallback((batchIndex: number) => { + setExpandedBatchIds(prev => { + const next = new Set(prev); + if (next.has(batchIndex)) { + next.delete(batchIndex); + } else { + next.add(batchIndex); + } + return next; + }); + }, []); + + const selectAllBatches = useCallback(() => { + if (!analysisResult) return; + const allIds = new Set(analysisResult.proposedBatches.map((_, idx) => idx)); + setSelectedBatchIds(allIds); + const allSingleIssues = new Set(analysisResult.singleIssues.map(issue => issue.iid)); + setSelectedSingleIids(allSingleIssues); + }, [analysisResult]); + + const deselectAllBatches = useCallback(() => { + setSelectedBatchIds(new Set()); + setSelectedSingleIids(new Set()); + }, []); + + const handleApprove = useCallback(async () => { + if (!analysisResult) return; + + // Get selected batches + const selectedBatches = analysisResult.proposedBatches.filter( + (_, idx) => selectedBatchIds.has(idx) + ); + + // Convert selected single issues into batches (each single issue becomes a batch of 1) + const selectedSingleIssueBatches: GitLabProposedBatch[] = analysisResult.singleIssues + .filter(issue => selectedSingleIids.has(issue.iid)) + .map(issue => ({ + primaryIssue: issue.iid, + issues: [{ + iid: issue.iid, + title: issue.title, + labels: issue.labels, + similarityToPrimary: 1.0 + }], + issueCount: 1, + commonThemes: [], + validated: true, + confidence: 1.0, + reasoning: 'Single issue - not grouped with others', + theme: issue.title + })); + + // Combine batches and single issues + const allBatches = [...selectedBatches, ...selectedSingleIssueBatches]; + + try { + await onApproveBatches(allBatches); + setStep('done'); + } catch (error) { + setApprovalError(error instanceof Error ? error.message : String(error)); + setStep('intro'); + } + }, [analysisResult, selectedBatchIds, selectedSingleIids, onApproveBatches]); + + const renderIntro = () => ( +
+
+ +
+
+

{t('gitlab:batchReview.title')}

+

+ {t('gitlab:batchReview.description')} +

+
+ {(analysisError || approvalError) && ( +
+ + {analysisError || approvalError} +
+ )} + +
+ ); + + const renderAnalyzing = () => ( +
+ +
+

{t('gitlab:batchReview.analyzing')}

+

+ {analysisProgress?.message || t('gitlab:batchReview.computingSimilarity')} +

+
+
+ +

+ {t('gitlab:batchReview.percentComplete', { value: analysisProgress?.progress ?? 0 })} +

+
+
+ ); + + const renderReview = () => { + if (!analysisResult) return null; + + const { proposedBatches, singleIssues, totalIssues } = analysisResult; + const selectedCount = selectedBatchIds.size; + const totalIssuesInSelected = proposedBatches + .filter((_, idx) => selectedBatchIds.has(idx)) + .reduce((sum, b) => sum + b.issueCount, 0); + + return ( +
+ {/* Stats Bar */} +
+
+ + {totalIssues} {t('gitlab:batchReview.issuesAnalyzed')} + + | + + {proposedBatches.length} {t('gitlab:batchReview.batchesProposed')} + + | + + {singleIssues.length} {t('gitlab:batchReview.singleIssues')} + +
+
+ + +
+
+ + {/* Batches List */} + +
+ {proposedBatches.map((batch, idx) => ( + toggleBatchSelection(idx)} + onToggleExpand={() => toggleBatchExpanded(idx)} + /> + ))} +
+ + {/* Single Issues Section */} + {singleIssues.length > 0 && ( +
+

+ {t('gitlab:batchReview.selectSingleIssues')} +

+
+ {singleIssues.slice(0, 10).map((issue) => ( +
toggleSingleIssueSelection(issue.iid)} + className={`p-2 rounded border text-sm truncate cursor-pointer transition-colors ${ + selectedSingleIids.has(issue.iid) + ? 'border-primary bg-primary/5' + : 'border-border hover:bg-accent' + }`} + > + e.stopPropagation()} + onCheckedChange={() => toggleSingleIssueSelection(issue.iid)} + /> + #{issue.iid}{' '} + {issue.title} +
+ ))} + {singleIssues.length > 10 && ( +
+ {t('gitlab:batchReview.andMore', { count: singleIssues.length - 10 })} +
+ )} +
+
+ )} +
+ + {/* Selection Summary */} +
+
+ {t('gitlab:batchReview.batchesSelected', { + count: selectedCount, + issues: totalIssuesInSelected + })} + {selectedSingleIids.size > 0 && ( + <> {t('gitlab:batchReview.plusSingleIssues', { count: selectedSingleIids.size })} + )} +
+
+
+ ); + }; + + const renderApproving = () => ( +
+ +
+

{t('gitlab:batchReview.creatingBatches')}

+

+ {t('gitlab:batchReview.settingUpBatches')} +

+
+
+ ); + + const renderDone = () => ( +
+
+ +
+
+

{t('gitlab:batchReview.batchesCreated')}

+

+ {t('gitlab:batchReview.batchesReady')} +

+
+ +
+ ); + + return ( + !open && onClose()}> + + + + + {t('gitlab:batchReview.title')} + + + {step === 'intro' && t('gitlab:batchReview.description')} + {step === 'analyzing' && t('gitlab:batchReview.computingSimilarity')} + {step === 'review' && t('gitlab:batchReview.description')} + {step === 'approving' && t('gitlab:batchReview.settingUpBatches')} + {step === 'done' && t('gitlab:batchReview.batchesReady')} + + + +
+ {step === 'intro' && renderIntro()} + {step === 'analyzing' && renderAnalyzing()} + {step === 'review' && renderReview()} + {step === 'approving' && renderApproving()} + {step === 'done' && renderDone()} +
+ + {step === 'review' && ( + + + + + )} +
+
+ ); +} + +interface GitLabBatchCardProps { + batch: GitLabProposedBatch; + index: number; + isSelected: boolean; + isExpanded: boolean; + onToggleSelect: () => void; + onToggleExpand: () => void; +} + +function GitLabBatchCard({ + batch, + index, + isSelected, + isExpanded, + onToggleSelect, + onToggleExpand, +}: GitLabBatchCardProps) { + const { t } = useTranslation(['gitlab']); + const confidenceColor = batch.confidence >= 0.8 + ? 'text-green-500' + : batch.confidence >= 0.6 + ? 'text-yellow-500' + : 'text-red-500'; + + return ( +
+
+ + + +
+ + {isExpanded ? ( + + ) : ( + + )} + + {batch.theme || t('gitlab:batchReview.batchNumber', { number: index + 1 })} + + + +
+ + + {batch.issueCount} {t('gitlab:batchReview.issues')} + + + {batch.validated ? ( + + ) : ( + + )} + + {Math.round(batch.confidence * 100)}% + + +
+
+ + + {/* Reasoning */} +

+ {batch.reasoning} +

+ + {/* Issues List */} +
+ {batch.issues.map((issue) => ( +
+
+ + #{issue.iid} + + {issue.title} +
+ + {t('gitlab:batchReview.similarPercent', { value: Math.round(issue.similarityToPrimary * 100) })} + +
+ ))} +
+ + {/* Themes */} + {batch.commonThemes.length > 0 && ( +
+ {batch.commonThemes.map((theme, i) => ( + + {theme} + + ))} +
+ )} +
+
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/components/gitlab-issues/components/index.ts b/apps/desktop/src/renderer/components/gitlab-issues/components/index.ts index 351ef8a1c3..a3b3ba2687 100644 --- a/apps/desktop/src/renderer/components/gitlab-issues/components/index.ts +++ b/apps/desktop/src/renderer/components/gitlab-issues/components/index.ts @@ -4,3 +4,5 @@ export { InvestigationDialog } from './InvestigationDialog'; export { EmptyState, NotConnectedState } from './EmptyStates'; export { IssueListHeader } from './IssueListHeader'; export { IssueList } from './IssueList'; +export { GitLabAutoFixButton } from './AutoFixButton'; +export { GitLabBatchReviewWizard } from './BatchReviewWizard'; diff --git a/apps/desktop/src/renderer/components/gitlab-issues/utils/__tests__/gitlab-error-parser.test.ts b/apps/desktop/src/renderer/components/gitlab-issues/utils/__tests__/gitlab-error-parser.test.ts new file mode 100644 index 0000000000..b253712601 --- /dev/null +++ b/apps/desktop/src/renderer/components/gitlab-issues/utils/__tests__/gitlab-error-parser.test.ts @@ -0,0 +1,57 @@ +/** + * Unit tests for GitLab error parser + */ +import { describe, it, expect } from 'vitest'; +import { + parseGitLabError, + isRecoverableGitLabError, + GitLabErrorCode +} from '../gitlab-error-parser'; + +describe('gitlab-error-parser', () => { + it('should parse 401 authentication errors', () => { + const error = new Error('401 Unauthorized'); + const parsed = parseGitLabError(error); + + expect(parsed.code).toBe(GitLabErrorCode.AUTHENTICATION_FAILED); + expect(parsed.recoverable).toBe(true); + }); + + it('should parse 403 permission errors', () => { + const error = new Error('403 Forbidden'); + const parsed = parseGitLabError(error); + + expect(parsed.code).toBe(GitLabErrorCode.INSUFFICIENT_PERMISSIONS); + expect(parsed.recoverable).toBe(false); + }); + + it('should parse 404 not found errors', () => { + const error = new Error('404 Not Found'); + const parsed = parseGitLabError(error); + + expect(parsed.code).toBe(GitLabErrorCode.PROJECT_NOT_FOUND); + expect(parsed.recoverable).toBe(false); + }); + + it('should parse 409 conflict errors', () => { + const error = new Error('409 Conflict'); + const parsed = parseGitLabError(error); + + expect(parsed.code).toBe(GitLabErrorCode.CONFLICT); + expect(parsed.recoverable).toBe(false); + }); + + it('should parse 429 rate limit errors', () => { + const error = 'Rate limit exceeded'; + const parsed = parseGitLabError(error); + + expect(parsed.code).toBe(GitLabErrorCode.RATE_LIMITED); + expect(parsed.recoverable).toBe(true); + }); + + it('should identify recoverable errors', () => { + expect(isRecoverableGitLabError(new Error('401 Unauthorized'))).toBe(true); + expect(isRecoverableGitLabError(new Error('Network error'))).toBe(true); + expect(isRecoverableGitLabError(new Error('409 Conflict'))).toBe(false); + }); +}); diff --git a/apps/desktop/src/renderer/components/gitlab-issues/utils/gitlab-error-parser.ts b/apps/desktop/src/renderer/components/gitlab-issues/utils/gitlab-error-parser.ts new file mode 100644 index 0000000000..d33bb4d918 --- /dev/null +++ b/apps/desktop/src/renderer/components/gitlab-issues/utils/gitlab-error-parser.ts @@ -0,0 +1,146 @@ +/** + * GitLab Error Parser Utility + * + * Parses GitLab API errors and returns error codes for i18n translation. + * Follows the same pattern as GitHub's error parser. + */ + +export enum GitLabErrorCode { + AUTHENTICATION_FAILED = 'AUTHENTICATION_FAILED', + RATE_LIMITED = 'RATE_LIMITED', + NETWORK_ERROR = 'NETWORK_ERROR', + PROJECT_NOT_FOUND = 'PROJECT_NOT_FOUND', + INSUFFICIENT_PERMISSIONS = 'INSUFFICIENT_PERMISSIONS', + CONFLICT = 'CONFLICT', + UNKNOWN = 'UNKNOWN' +} + +export interface ParsedGitLabError { + code: GitLabErrorCode; + recoverable: boolean; + details?: string; +} + +/** + * Parse a GitLab error and return an error code + */ +export function parseGitLabError(error: unknown): ParsedGitLabError { + if (error instanceof Error) { + return parseGitLabErrorMessage(error.message); + } + + if (typeof error === 'string') { + return parseGitLabErrorMessage(error); + } + + // Handle Error-like objects with a message property + if (typeof error === 'object' && error !== null && 'message' in error) { + const message = (error as { message: string }).message; + if (typeof message === 'string') { + return parseGitLabErrorMessage(message); + } + } + + return { + code: GitLabErrorCode.UNKNOWN, + recoverable: false + }; +} + +/** + * Parse GitLab error message + */ +function parseGitLabErrorMessage(message: string): ParsedGitLabError { + const lowerMessage = message.toLowerCase(); + + // Check for explicit HTTP status code in response (if available) + // Try to extract status from common patterns like "Status: 401" or HTTP error responses + const statusMatch = message.match(/\bstatus:\s*(\d{3})\b/i) || + message.match(/\bhttp\s+(\d{3})\b/i) || + lowerMessage.match(/\b"status":\s*(\d{3})\b/); + + if (statusMatch) { + const statusCode = parseInt(statusMatch[1], 10); + switch (statusCode) { + case 401: + return { code: GitLabErrorCode.AUTHENTICATION_FAILED, recoverable: true }; + case 403: + return { code: GitLabErrorCode.INSUFFICIENT_PERMISSIONS, recoverable: false }; + case 404: + return { code: GitLabErrorCode.PROJECT_NOT_FOUND, recoverable: false }; + case 409: + return { code: GitLabErrorCode.CONFLICT, recoverable: false }; + case 429: + return { code: GitLabErrorCode.RATE_LIMITED, recoverable: true }; + } + } + + // Fallback to message content analysis with word-boundary regex to avoid false matches + // Authentication errors + if (/\b401\b/.test(message) || lowerMessage.includes('unauthorized') || lowerMessage.includes('invalid token')) { + return { + code: GitLabErrorCode.AUTHENTICATION_FAILED, + recoverable: true + }; + } + + // Rate limiting (429) + if (/\b429\b/.test(message) || lowerMessage.includes('rate limit') || lowerMessage.includes('too many requests')) { + return { + code: GitLabErrorCode.RATE_LIMITED, + recoverable: true + }; + } + + // Network errors - use specific phrases to avoid false positives + if (lowerMessage.includes('network') || + lowerMessage.includes('connection refused') || + lowerMessage.includes('connection failed') || + lowerMessage.includes('unable to connect') || + lowerMessage.includes('connect timeout') || + lowerMessage.includes('timeout')) { + return { + code: GitLabErrorCode.NETWORK_ERROR, + recoverable: true + }; + } + + // Project not found (404) - not recoverable + if (/\b404\b/.test(message) || lowerMessage.includes('not found')) { + return { + code: GitLabErrorCode.PROJECT_NOT_FOUND, + recoverable: false + }; + } + + // Permission denied (403) - not recoverable + if (/\b403\b/.test(message) || lowerMessage.includes('forbidden') || lowerMessage.includes('permission denied')) { + return { + code: GitLabErrorCode.INSUFFICIENT_PERMISSIONS, + recoverable: false + }; + } + + // Conflict (409) + if (/\b409\b/.test(message) || lowerMessage.includes('conflict')) { + return { + code: GitLabErrorCode.CONFLICT, + recoverable: false + }; + } + + // Default error + return { + code: GitLabErrorCode.UNKNOWN, + recoverable: false, + details: message + }; +} + +/** + * Check if an error is recoverable + */ +export function isRecoverableGitLabError(error: unknown): boolean { + const parsed = parseGitLabError(error); + return parsed.recoverable; +} diff --git a/apps/desktop/src/renderer/components/gitlab-merge-requests/components/MRFilterBar.tsx b/apps/desktop/src/renderer/components/gitlab-merge-requests/components/MRFilterBar.tsx new file mode 100644 index 0000000000..61e81fd294 --- /dev/null +++ b/apps/desktop/src/renderer/components/gitlab-merge-requests/components/MRFilterBar.tsx @@ -0,0 +1,568 @@ +/** + * Filter bar for GitLab MRs list + * Grid layout: Contributors (3) | Status (3) | Search (8) + * Multi-select dropdowns with visible chip selections + * + * Stub component - implements the same pattern as PRFilterBar + * adapted for GitLab merge requests. + */ + +import { useState, useMemo, useRef, useCallback, useEffect } from 'react'; +import { + Search, + Users, + Sparkles, + CheckCircle2, + Send, + AlertCircle, + CheckCheck, + RefreshCw, + X, + Filter, + Check, + Loader2, + ArrowUpDown, + Clock, + FileCode +} from 'lucide-react'; +import { Input } from '@/components/ui/input'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Separator } from '@/components/ui/separator'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { useTranslation } from 'react-i18next'; +import type { GitLabMRFilterState, GitLabMRStatusFilter, GitLabMRSortOption } from '../hooks/useGitLabMRFiltering'; +import { cn } from '@/lib/utils'; + +interface MRFilterBarProps { + filters: GitLabMRFilterState; + contributors: string[]; + hasActiveFilters: boolean; + onSearchChange: (query: string) => void; + onContributorsChange: (contributors: string[]) => void; + onStatusesChange: (statuses: GitLabMRStatusFilter[]) => void; + onSortChange: (sortBy: GitLabMRSortOption) => void; + onClearFilters: () => void; +} + +// Status options +const STATUS_OPTIONS: Array<{ + value: GitLabMRStatusFilter; + labelKey: string; + icon: typeof Sparkles; + color: string; + bgColor: string; +}> = [ + { value: 'reviewing', labelKey: 'mrFiltering.reviewing', icon: Loader2, color: 'text-amber-400', bgColor: 'bg-amber-500/20' }, + { value: 'not_reviewed', labelKey: 'mrFiltering.notReviewed', icon: Sparkles, color: 'text-slate-500', bgColor: 'bg-slate-500/20' }, + { value: 'reviewed', labelKey: 'mrFiltering.reviewed', icon: CheckCircle2, color: 'text-blue-400', bgColor: 'bg-blue-500/20' }, + { value: 'posted', labelKey: 'mrFiltering.posted', icon: Send, color: 'text-purple-400', bgColor: 'bg-purple-500/20' }, + { value: 'changes_requested', labelKey: 'mrFiltering.changesRequested', icon: AlertCircle, color: 'text-red-400', bgColor: 'bg-red-500/20' }, + { value: 'ready_to_merge', labelKey: 'mrFiltering.readyToMerge', icon: CheckCheck, color: 'text-emerald-400', bgColor: 'bg-emerald-500/20' }, + { value: 'ready_for_followup', labelKey: 'mrFiltering.readyForFollowup', icon: RefreshCw, color: 'text-cyan-400', bgColor: 'bg-cyan-500/20' }, +]; + +// Sort options +const SORT_OPTIONS: Array<{ + value: GitLabMRSortOption; + labelKey: string; + icon: typeof Clock; +}> = [ + { value: 'newest', labelKey: 'mrFiltering.sort.newest', icon: Clock }, + { value: 'oldest', labelKey: 'mrFiltering.sort.oldest', icon: Clock }, + { value: 'largest', labelKey: 'mrFiltering.sort.largest', icon: FileCode }, +]; + +/** + * Modern Filter Dropdown Component + */ +function FilterDropdown({ + title, + icon: Icon, + items, + selected, + onChange, + renderItem, + renderTrigger, + searchable = false, + searchPlaceholder, + selectedCountLabel, + noResultsLabel, + clearLabel, +}: { + title: string; + icon: typeof Users; + items: T[]; + selected: T[]; + onChange: (selected: T[]) => void; + renderItem?: (item: T) => React.ReactNode; + renderTrigger?: (selected: T[]) => React.ReactNode; + searchable?: boolean; + searchPlaceholder?: string; + selectedCountLabel?: string; + noResultsLabel?: string; + clearLabel?: string; +}) { + const [searchTerm, setSearchTerm] = useState(''); + const [isOpen, setIsOpen] = useState(false); + const [focusedIndex, setFocusedIndex] = useState(-1); + const itemRefs = useRef<(HTMLDivElement | null)[]>([]); + + const toggleItem = useCallback((item: T) => { + if (selected.includes(item)) { + onChange(selected.filter((s) => s !== item)); + } else { + onChange([...selected, item]); + } + }, [selected, onChange]); + + const filteredItems = useMemo(() => { + if (!searchTerm) return items; + return items.filter(item => + item.toLowerCase().includes(searchTerm.toLowerCase()) + ); + }, [items, searchTerm]); + + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (filteredItems.length === 0) return; + + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + setFocusedIndex(prev => + prev < filteredItems.length - 1 ? prev + 1 : 0 + ); + break; + case 'ArrowUp': + e.preventDefault(); + setFocusedIndex(prev => + prev > 0 ? prev - 1 : filteredItems.length - 1 + ); + break; + case 'Enter': + case ' ': + e.preventDefault(); + if (focusedIndex >= 0 && focusedIndex < filteredItems.length) { + toggleItem(filteredItems[focusedIndex]); + } + break; + case 'Escape': + setIsOpen(false); + break; + } + }, [filteredItems, focusedIndex, toggleItem]); + + // Scroll focused item into view for keyboard navigation + useEffect(() => { + if (focusedIndex >= 0 && itemRefs.current[focusedIndex]) { + itemRefs.current[focusedIndex]?.scrollIntoView({ block: 'nearest' }); + } + }, [focusedIndex]); + + return ( + { + setIsOpen(open); + if (!open) { + setSearchTerm(''); + setFocusedIndex(-1); + } + }}> + + + + +
+
+ {title} +
+ {searchable && ( +
+ + setSearchTerm(e.target.value)} + onKeyDown={(e) => e.stopPropagation()} + /> +
+ )} +
+ +
+ {filteredItems.length === 0 ? ( +
+ {noResultsLabel} +
+ ) : ( + filteredItems.map((item, index) => { + const isSelected = selected.includes(item); + const isFocused = index === focusedIndex; + return ( +
{ itemRefs.current[index] = el; }} + role="option" + aria-selected={isSelected} + className={cn( + "relative flex cursor-pointer select-none items-center rounded-sm px-2 py-2 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", + isSelected && "bg-accent/50", + isFocused && "ring-2 ring-primary/50 bg-accent" + )} + onClick={(e) => { + e.preventDefault(); + toggleItem(item); + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + toggleItem(item); + } + }} + tabIndex={-1} + > +
+ +
+ {renderItem ? renderItem(item) : item} +
+ ); + }) + )} +
+ + {selected.length > 0 && ( +
+ +
+ )} +
+
+ ); +} + +/** + * Single-select Sort Dropdown Component + */ +function SortDropdown({ + value, + onChange, + options, + title, +}: { + value: GitLabMRSortOption; + onChange: (value: GitLabMRSortOption) => void; + options: typeof SORT_OPTIONS; + title: string; +}) { + const { t } = useTranslation('gitlab'); + const [isOpen, setIsOpen] = useState(false); + const [focusedIndex, setFocusedIndex] = useState(-1); + + const currentOption = options.find((opt) => opt.value === value) || options[0]; + + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (options.length === 0) return; + + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + setFocusedIndex((prev) => (prev < options.length - 1 ? prev + 1 : 0)); + break; + case 'ArrowUp': + e.preventDefault(); + setFocusedIndex((prev) => (prev > 0 ? prev - 1 : options.length - 1)); + break; + case 'Enter': + case ' ': + e.preventDefault(); + if (focusedIndex >= 0 && focusedIndex < options.length) { + onChange(options[focusedIndex].value); + setIsOpen(false); + } + break; + case 'Escape': + setIsOpen(false); + break; + } + }, [options, focusedIndex, onChange]); + + return ( + { + setIsOpen(open); + if (open) { + // Focus current selection on open for better keyboard UX + setFocusedIndex(options.findIndex((o) => o.value === value)); + } else { + setFocusedIndex(-1); + } + }} + > + + + + +
+
+ {title} +
+
+
+ {options.map((option, index) => { + const isSelected = value === option.value; + const isFocused = focusedIndex === index; + const Icon = option.icon; + return ( +
{ + onChange(option.value); + setIsOpen(false); + }} + > +
+ {isSelected && } +
+ + {t(option.labelKey)} +
+ ); + })} +
+
+
+ ); +} + +export function MRFilterBar({ + filters, + contributors, + hasActiveFilters, + onSearchChange, + onContributorsChange, + onStatusesChange, + onSortChange, + onClearFilters, +}: MRFilterBarProps) { + const { t } = useTranslation('gitlab'); + + // Get status option by value + const getStatusOption = (value: GitLabMRStatusFilter) => + STATUS_OPTIONS.find((opt) => opt.value === value); + + return ( +
+
+ {/* Search Input - Flexible width */} +
+ + onSearchChange(e.target.value)} + className="h-8 pl-9 bg-background/50 focus:bg-background transition-colors" + /> + {filters.searchQuery && ( + + )} +
+ + + + {/* Contributors Filter */} +
+ ( +
+
+ + {contributor.slice(0, 2).toUpperCase()} + +
+ {contributor} +
+ )} + /> +
+ + {/* Status Filter */} +
+ opt.value)} + selected={filters.statuses} + onChange={onStatusesChange} + selectedCountLabel={t('mrFiltering.selectedCount', { count: filters.statuses.length })} + noResultsLabel={t('mrFiltering.noResultsFound')} + clearLabel={t('mrFiltering.clearFilters')} + renderItem={(status) => { + const option = getStatusOption(status); + if (!option) return null; + const Icon = option.icon; + return ( +
+
+ +
+ {t(option.labelKey)} +
+ ); + }} + renderTrigger={(selected) => ( + selected.map(status => { + const option = getStatusOption(status); + if (!option) return null; + const Icon = option.icon; + return ( + + + {t(option.labelKey)} + + ); + }) + )} + /> +
+ + {/* Sort Dropdown */} +
+ +
+ + {/* Reset All */} + {hasActiveFilters && ( + + )} +
+
+ ); +} diff --git a/apps/desktop/src/renderer/components/gitlab-merge-requests/components/MRLogs.tsx b/apps/desktop/src/renderer/components/gitlab-merge-requests/components/MRLogs.tsx new file mode 100644 index 0000000000..bd602d27a0 --- /dev/null +++ b/apps/desktop/src/renderer/components/gitlab-merge-requests/components/MRLogs.tsx @@ -0,0 +1,732 @@ +/** + * MR Logs Component + * + * Displays detailed logs from GitLab merge request review operations. + * Shows AI analysis phases, agent activities, and review progress. + * + * Stub component - implements the same pattern as PRLogs + * adapted for GitLab merge requests. + */ + +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Terminal, + Loader2, + FolderOpen, + BrainCircuit, + FileCheck, + CheckCircle2, + XCircle, + ChevronDown, + ChevronRight, + Info, + Clock, + Activity +} from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible'; +import { cn } from '@/lib/utils'; +import type { + PRLogs, + PRLogPhase, + PRPhaseLog, + PRLogEntry +} from '@preload/api/modules/github-api'; + +// Type aliases for GitLab compatibility +type GitLabMRLogs = PRLogs; +type GitLabMRLogPhase = PRLogPhase; +type GitLabMRPhaseLog = PRPhaseLog; +type GitLabMRLogEntry = PRLogEntry; + +interface MRLogsProps { + mrIid: number; + logs: GitLabMRLogs | null; + isLoading: boolean; + isStreaming?: boolean; +} + +// TODO: The GITLAB_MR_GET_LOGS IPC handler returns string[] but this component expects PRLogs. +// Add a transformation to convert string[] to PRLogs structure in the handler or a data layer. +// For now, handle both formats defensively. + +// Phase label translation key mapping +const PHASE_LABEL_KEYS: Record = { + context: 'gitlab:mrFiltering.logs.contextGathering', + analysis: 'gitlab:mrFiltering.logs.aiAnalysis', + synthesis: 'gitlab:mrFiltering.logs.synthesis', +}; + +// Helper function to get phase labels with translation +function getPhaseLabel(phase: GitLabMRLogPhase, t: (key: string) => string): string { + return t(PHASE_LABEL_KEYS[phase]); +} + +const PHASE_ICONS: Record = { + context: FolderOpen, + analysis: BrainCircuit, + synthesis: FileCheck +}; + +const PHASE_COLORS: Record = { + context: 'text-blue-500 bg-blue-500/10 border-blue-500/30', + analysis: 'text-purple-500 bg-purple-500/10 border-purple-500/30', + synthesis: 'text-green-500 bg-green-500/10 border-green-500/30' +}; + +// Source colors for different log sources +const SOURCE_COLORS: Record = { + 'Context': 'bg-blue-500/20 text-blue-400', + 'AI': 'bg-purple-500/20 text-purple-400', + 'Orchestrator': 'bg-orange-500/20 text-orange-400', + 'ParallelOrchestrator': 'bg-orange-500/20 text-orange-400', + 'Followup': 'bg-cyan-500/20 text-cyan-400', + 'ParallelFollowup': 'bg-cyan-500/20 text-cyan-400', + 'BotDetector': 'bg-amber-500/20 text-amber-400', + 'Progress': 'bg-green-500/20 text-green-400', + 'MR Review Engine': 'bg-indigo-500/20 text-indigo-400', + 'Summary': 'bg-emerald-500/20 text-emerald-400', + 'Agent:logic-reviewer': 'bg-blue-600/20 text-blue-400', + 'Agent:quality-reviewer': 'bg-indigo-600/20 text-indigo-400', + 'Agent:security-reviewer': 'bg-red-600/20 text-red-400', + 'Agent:ai-triage-reviewer': 'bg-slate-500/20 text-slate-400', + 'Agent:resolution-verifier': 'bg-teal-600/20 text-teal-400', + 'Agent:new-code-reviewer': 'bg-cyan-600/20 text-cyan-400', + 'Agent:comment-analyzer': 'bg-gray-500/20 text-gray-400', + 'Specialist:security': 'bg-red-600/20 text-red-400', + 'Specialist:quality': 'bg-indigo-600/20 text-indigo-400', + 'Specialist:logic': 'bg-blue-600/20 text-blue-400', + 'Specialist:codebase-fit': 'bg-emerald-600/20 text-emerald-400', + 'FindingValidator': 'bg-amber-600/20 text-amber-400', + 'default': 'bg-muted text-muted-foreground' +}; + +// Helper type for grouped agent entries +interface AgentGroup { + agentName: string; + entries: GitLabMRLogEntry[]; +} + +// Patterns that indicate orchestrator tool activity (vs. important messages) +const TOOL_ACTIVITY_PATTERNS = [ + /^Reading /, + /^Searching for /, + /^Finding files /, + /^Running: /, + /^Editing /, + /^Writing /, + /^Using tool: /, + /^Processing\.\.\. \(\d+ messages/, + /^Tool result \[/, +]; + +function isToolActivityLog(content: string): boolean { + return TOOL_ACTIVITY_PATTERNS.some(pattern => pattern.test(content)); +} + +// Group entries by: agents, orchestrator activity, and other entries +function groupEntriesByAgent(entries: GitLabMRLogEntry[]): { + agentGroups: AgentGroup[]; + orchestratorActivity: GitLabMRLogEntry[]; + otherEntries: GitLabMRLogEntry[]; +} { + const agentMap = new Map(); + const orchestratorActivity: GitLabMRLogEntry[] = []; + const otherEntries: GitLabMRLogEntry[] = []; + + for (const entry of entries) { + if (entry.source?.startsWith('Agent:') || entry.source?.startsWith('Specialist:')) { + const existing = agentMap.get(entry.source) || []; + existing.push(entry); + agentMap.set(entry.source, existing); + } else if ( + (entry.source === 'ParallelOrchestrator' || entry.source === 'ParallelFollowup') && + isToolActivityLog(entry.content) + ) { + orchestratorActivity.push(entry); + } else { + otherEntries.push(entry); + } + } + + const agentGroups: AgentGroup[] = Array.from(agentMap.entries()) + .map(([agentName, agentEntries]) => ({ agentName, entries: agentEntries })) + .sort((a, b) => { + const aTime = a.entries[0]?.timestamp || ''; + const bTime = b.entries[0]?.timestamp || ''; + return aTime.localeCompare(bTime); + }); + + return { agentGroups, orchestratorActivity, otherEntries }; +} + +export function MRLogs({ mrIid, logs, isLoading, isStreaming = false }: MRLogsProps) { + const { t } = useTranslation(['gitlab']); + const [expandedPhases, setExpandedPhases] = useState>(new Set(['analysis'])); + const [expandedAgents, setExpandedAgents] = useState>(new Set()); + + // TODO: Remove this fallback when GITLAB_MR_GET_LOGS returns structured PRLogs + const logsAsArray = Array.isArray(logs) ? logs : null; + + const togglePhase = (phase: GitLabMRLogPhase) => { + setExpandedPhases(prev => { + const next = new Set(prev); + if (next.has(phase)) { + next.delete(phase); + } else { + next.add(phase); + } + return next; + }); + }; + + const toggleAgent = (agentKey: string) => { + setExpandedAgents(prev => { + const next = new Set(prev); + if (next.has(agentKey)) { + next.delete(agentKey); + } else { + next.add(agentKey); + } + return next; + }); + }; + + return ( +
+
+ {isLoading && !logs ? ( +
+ +
+ ) : logsAsArray ? ( + // Fallback for string[] format (current IPC handler return type) + // TODO: Remove when GITLAB_MR_GET_LOGS returns structured PRLogs + <> +
+ {t('gitlab:mrFiltering.logs.mrLabel', { iid: mrIid })} +
+
+ {logsAsArray.map((log, idx) => ( +
{log}
+ ))} +
+ + ) : logs ? ( + <> + {/* Logs header */} +
+
+ {t('gitlab:mrFiltering.logs.mrLabel', { iid: mrIid })} + {logs.is_followup && {t('gitlab:mrFiltering.logs.followup')}} + {isStreaming && ( + + + {t('gitlab:mrFiltering.logs.live')} + + )} +
+
+ + {new Date(logs.updated_at).toLocaleString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + })} +
+
+ + {/* Phase-based collapsible logs */} + {(['context', 'analysis', 'synthesis'] as GitLabMRLogPhase[]).map((phase) => ( + togglePhase(phase)} + isStreaming={isStreaming} + expandedAgents={expandedAgents} + onToggleAgent={toggleAgent} + /> + ))} + + ) : isStreaming ? ( +
+ +

{t('gitlab:mrFiltering.logs.waitingForLogs')}

+

{t('gitlab:mrFiltering.logs.reviewStarting')}

+
+ ) : ( +
+ +

{t('gitlab:mrFiltering.logs.noLogsAvailable')}

+

{t('gitlab:mrFiltering.logs.runReviewGenerateLogs')}

+
+ )} +
+
+ ); +} + +// Phase Log Section Component +interface PhaseLogSectionProps { + phase: GitLabMRLogPhase; + phaseLog: GitLabMRPhaseLog | null; + isExpanded: boolean; + onToggle: () => void; + isStreaming?: boolean; + expandedAgents: Set; + onToggleAgent: (agentKey: string) => void; +} + +function PhaseLogSection({ phase, phaseLog, isExpanded, onToggle, isStreaming = false, expandedAgents, onToggleAgent }: PhaseLogSectionProps) { + const { t } = useTranslation(['gitlab']); + const Icon = PHASE_ICONS[phase]; + const status = phaseLog?.status || 'pending'; + const hasEntries = (phaseLog?.entries.length || 0) > 0; + + const getStatusBadge = () => { + if (status === 'active' || (isStreaming && status === 'pending')) { + return ( + + + {isStreaming ? t('gitlab:mrFiltering.logs.streaming') : t('gitlab:mrFiltering.logs.running')} + + ); + } + + if (isStreaming && status === 'completed' && !hasEntries) { + return ( + + {t('gitlab:mrFiltering.logs.pending')} + + ); + } + + switch (status) { + case 'completed': + return ( + + + {t('gitlab:mrFiltering.logs.complete')} + + ); + case 'failed': + return ( + + + {t('gitlab:mrFiltering.logs.failed')} + + ); + default: + return ( + + {t('gitlab:mrFiltering.logs.pending')} + + ); + } + }; + + return ( + + + + + +
+ {!hasEntries ? ( +

{t('gitlab:mrFiltering.logs.noLogsYet')}

+ ) : ( + + )} +
+
+
+ ); +} + +// Grouped Log Entries Component +interface GroupedLogEntriesProps { + entries: GitLabMRLogEntry[]; + phase: GitLabMRLogPhase; + expandedAgents: Set; + onToggleAgent: (agentKey: string) => void; +} + +function GroupedLogEntries({ entries, phase, expandedAgents, onToggleAgent }: GroupedLogEntriesProps) { + const { agentGroups, orchestratorActivity, otherEntries } = groupEntriesByAgent(entries); + + return ( +
+ {otherEntries.length > 0 && ( +
+ {otherEntries.map((entry, idx) => ( + + ))} +
+ )} + + {orchestratorActivity.length > 0 && ( + onToggleAgent(`${phase}-orchestrator-activity`)} + /> + )} + + {agentGroups.map((group) => ( + onToggleAgent(`${phase}-${group.agentName}`)} + /> + ))} +
+ ); +} + +// Orchestrator Activity Section +interface OrchestratorActivitySectionProps { + entries: GitLabMRLogEntry[]; + phase: GitLabMRLogPhase; + isExpanded: boolean; + onToggle: () => void; +} + +function OrchestratorActivitySection({ entries, isExpanded, onToggle }: OrchestratorActivitySectionProps) { + const { t } = useTranslation(['gitlab']); + + const readCount = entries.filter(e => e.content.startsWith('Reading ')).length; + const searchCount = entries.filter(e => e.content.startsWith('Searching for ')).length; + const otherCount = entries.length - readCount - searchCount; + + const summaryParts: string[] = []; + if (readCount > 0) { + summaryParts.push(t('gitlab:mrFiltering.logs.filesRead', { count: readCount })); + } + if (searchCount > 0) { + summaryParts.push(t('gitlab:mrFiltering.logs.searches', { count: searchCount })); + } + if (otherCount > 0) { + summaryParts.push(t('gitlab:mrFiltering.logs.other', { count: otherCount })); + } + const summary = summaryParts.join(', ') || t('gitlab:mrFiltering.logs.operations', { count: entries.length }); + + return ( +
+ + + {isExpanded && ( +
+ {entries.map((entry, idx) => ( +
+ + {new Date(entry.timestamp).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' })} + + {entry.content} +
+ ))} +
+ )} +
+ ); +} + +// Agent Log Group Component +interface AgentLogGroupProps { + group: AgentGroup; + phase: GitLabMRLogPhase; + isExpanded: boolean; + onToggle: () => void; +} + +const SKIP_AS_SUMMARY_PATTERNS = [ + /^Starting analysis\.\.\.$/, + /^Processing SDK stream\.\.\.$/, + /^Processing\.\.\./, + /^Awaiting response stream\.\.\.$/, +]; + +function isBoringSummary(content: string): boolean { + return SKIP_AS_SUMMARY_PATTERNS.some(pattern => pattern.test(content)); +} + +function findSummaryEntry(entries: GitLabMRLogEntry[]): { summaryEntry: GitLabMRLogEntry | undefined; otherEntries: GitLabMRLogEntry[] } { + if (entries.length === 0) return { summaryEntry: undefined, otherEntries: [] }; + + const completeEntry = entries.find(e => e.content.startsWith('Complete:')); + if (completeEntry) { + return { + summaryEntry: completeEntry, + otherEntries: entries.filter(e => e !== completeEntry), + }; + } + + const aiResponseEntry = entries.find(e => e.content.startsWith('AI response:')); + if (aiResponseEntry) { + return { + summaryEntry: aiResponseEntry, + otherEntries: entries.filter(e => e !== aiResponseEntry), + }; + } + + const meaningfulEntry = entries.find(e => !isBoringSummary(e.content)); + if (meaningfulEntry) { + return { + summaryEntry: meaningfulEntry, + otherEntries: entries.filter(e => e !== meaningfulEntry), + }; + } + + return { + summaryEntry: entries[0], + otherEntries: entries.slice(1), + }; +} + +function AgentLogGroup({ group, isExpanded, onToggle }: AgentLogGroupProps) { + const { t } = useTranslation(['common']); + const { agentName, entries } = group; + const { summaryEntry, otherEntries } = findSummaryEntry(entries); + const hasMoreEntries = otherEntries.length > 0; + const displayName = agentName.replace('Agent:', '').replace('Specialist:', ''); + + const getSourceColor = (source: string) => { + return SOURCE_COLORS[source] || SOURCE_COLORS.default; + }; + + return ( +
+
+
+ + {displayName} + + {hasMoreEntries && ( + + )} +
+ + {summaryEntry && ( + + )} +
+ + {hasMoreEntries && isExpanded && ( +
+ {otherEntries.map((entry, idx) => ( + + ))} +
+ )} +
+ ); +} + +// Log Entry Component +interface LogEntryProps { + entry: GitLabMRLogEntry; +} + +function LogEntry({ entry }: LogEntryProps) { + const { t } = useTranslation(['gitlab']); + const [isExpanded, setIsExpanded] = useState(false); + const hasDetail = Boolean(entry.detail); + + const formatTime = (timestamp: string) => { + try { + const date = new Date(timestamp); + return date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' }); + } catch { + return ''; + } + }; + + const getSourceColor = (source: string | undefined) => { + if (!source) return SOURCE_COLORS.default; + return SOURCE_COLORS[source] || SOURCE_COLORS.default; + }; + + if (entry.type === 'error') { + return ( +
+
+ + {entry.content} + {hasDetail && ( + + )} +
+ {hasDetail && isExpanded && ( +
+
+              {entry.detail}
+            
+
+ )} +
+ ); + } + + if (entry.type === 'success') { + return ( +
+ + {entry.content} +
+ ); + } + + if (entry.type === 'info') { + return ( +
+ + {entry.content} +
+ ); + } + + return ( +
+
+ + {formatTime(entry.timestamp)} + + {entry.source && ( + + {entry.source} + + )} + {entry.content} + {hasDetail && ( + + )} +
+ {hasDetail && isExpanded && ( +
+
+            {entry.detail}
+          
+
+ )} +
+ ); +} diff --git a/apps/desktop/src/renderer/components/gitlab-merge-requests/components/StatusIndicator.tsx b/apps/desktop/src/renderer/components/gitlab-merge-requests/components/StatusIndicator.tsx new file mode 100644 index 0000000000..e3f5e5ddf0 --- /dev/null +++ b/apps/desktop/src/renderer/components/gitlab-merge-requests/components/StatusIndicator.tsx @@ -0,0 +1,246 @@ +import { CheckCircle2, Circle, XCircle, Loader2, AlertTriangle, GitMerge, HelpCircle } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import { cn } from '@/lib/utils'; +import type { ChecksStatus, ReviewsStatus, MergeableState } from '@shared/types/pr-status'; +import { useTranslation } from 'react-i18next'; + +/** + * CI Status Icon Component + * Displays an icon representing the CI checks status + */ +interface CIStatusIconProps { + status: ChecksStatus; + className?: string; +} + +function CIStatusIcon({ status, className }: CIStatusIconProps) { + const baseClasses = 'h-4 w-4'; + + switch (status) { + case 'success': + return ; + case 'pending': + return ; + case 'failure': + return ; + default: + return ; + } +} + +/** + * Review Status Badge Component + * Displays a badge representing the review status + */ +interface ReviewStatusBadgeProps { + status: ReviewsStatus; + className?: string; +} + +function ReviewStatusBadge({ status, className }: ReviewStatusBadgeProps) { + const { t } = useTranslation('common'); + + switch (status) { + case 'approved': + return ( + + + {t('prStatus.review.approved')} + + ); + case 'changes_requested': + return ( + + + {t('prStatus.review.changesRequested')} + + ); + case 'pending': + return ( + + + {t('prStatus.review.pending')} + + ); + default: + return null; + } +} + +/** + * Merge Readiness Icon Component + * Displays an icon representing the merge readiness state + */ +interface MergeReadinessIconProps { + state: MergeableState; + className?: string; +} + +function MergeReadinessIcon({ state, className }: MergeReadinessIconProps) { + const baseClasses = 'h-4 w-4'; + + switch (state) { + case 'clean': + return ; + case 'dirty': + return ; + case 'blocked': + return ; + default: + return ; + } +} + +/** + * StatusIndicator Props + */ +export interface MRStatusIndicatorProps { + /** CI checks status */ + checksStatus?: ChecksStatus | null; + /** Review status */ + reviewsStatus?: ReviewsStatus | null; + /** Raw GitLab merge status string (e.g., 'can_be_merged', 'cannot_be_merged', 'checking') */ + mergeStatus?: string | null; + /** Additional CSS classes */ + className?: string; + /** Whether to show a compact version (icons only) */ + compact?: boolean; + /** Whether to show the merge readiness indicator */ + showMergeStatus?: boolean; +} + +/** + * StatusIndicator Component + * + * Displays CI status (success/pending/failure icons), review status + * (approved/changes_requested/pending badges), and merge readiness + * for GitLab MRs in the MR list view. + * + * Used alongside the existing MRStatusFlow dots component to provide + * real-time MR status from GitLab's API polling. + */ +// Comprehensive merge status mapping for all GitLab detailed_merge_status values +const mergeKeyMap: Record = { + can_be_merged: 'ready', + mergeable: 'ready', + cannot_be_merged: 'conflict', + conflict: 'conflict', + need_rebase: 'conflict', + checking: 'checking', + // Additional GitLab merge status values + policies: 'blocked', + merge_when_pipeline_succeeds: 'merging', + pipeline_failed: 'conflict', + pipeline_success: 'ready', + cant_be_merged: 'conflict', + blocked: 'blocked', + unchecked: 'checking', + web_ide: 'checking', + ci_must_pass: 'blocked', + ci_still_running: 'blocked', + discussions_not_resolved: 'blocked', + draft_status: 'blocked', + not_open: 'blocked', + merge_request_blocked: 'blocked', + // Safe default for unknown statuses +}; + +// Map GitLab merge status to MergeableState for the icon +const gitlabToMergeableState: Record = { + can_be_merged: 'clean', + mergeable: 'clean', + cannot_be_merged: 'dirty', + conflict: 'dirty', + need_rebase: 'dirty', + checking: 'blocked', + // Additional GitLab merge status values + policies: 'blocked', + merge_when_pipeline_succeeds: 'clean', + pipeline_failed: 'dirty', + pipeline_success: 'clean', + cant_be_merged: 'dirty', + blocked: 'blocked', + unchecked: 'blocked', + web_ide: 'blocked', + ci_must_pass: 'blocked', + ci_still_running: 'blocked', + discussions_not_resolved: 'blocked', + draft_status: 'blocked', + not_open: 'blocked', + merge_request_blocked: 'blocked', + // Safe default +}; + +export function MRStatusIndicator({ + checksStatus, + reviewsStatus, + mergeStatus, + className, + compact = false, + showMergeStatus = true, +}: MRStatusIndicatorProps) { + const { t } = useTranslation('common'); + + // Check if any renderable status data is available + const showChecks = Boolean(checksStatus && checksStatus !== 'none'); + const showReviews = Boolean(reviewsStatus && reviewsStatus !== 'none'); + const showMerge = Boolean(mergeStatus); + + // Don't render if no renderable status data is available + if (!showChecks && !showReviews && !showMerge) { + return null; + } + + const mergeKey = mergeStatus ? mergeKeyMap[mergeStatus] : null; + const mergeableState = mergeStatus ? gitlabToMergeableState[mergeStatus] : null; + + return ( +
+ {/* CI Status */} + {showChecks && ( +
+ + {!compact && ( + + {t(`mrStatus.ci.${checksStatus}`)} + + )} +
+ )} + + {/* Review Status */} + {showReviews && ( + compact ? ( + + ) : ( + + ) + )} + + {/* Merge Readiness */} + {showMergeStatus && mergeKey && mergeableState && ( +
+ + {!compact && ( + + {t(`mrStatus.merge.${mergeKey}`)} + + )} +
+ )} +
+ ); +} + +/** + * Compact Status Indicator + * + * A minimal version showing just icons with tooltips. + * Useful for tight spaces in the MR list. + */ +export function CompactMRStatusIndicator(props: Omit) { + return ; +} + +// Re-export sub-components for flexibility +export { CIStatusIcon, ReviewStatusBadge, MergeReadinessIcon }; diff --git a/apps/desktop/src/renderer/components/gitlab-merge-requests/components/index.ts b/apps/desktop/src/renderer/components/gitlab-merge-requests/components/index.ts index 2d4fa16e24..285b04e250 100644 --- a/apps/desktop/src/renderer/components/gitlab-merge-requests/components/index.ts +++ b/apps/desktop/src/renderer/components/gitlab-merge-requests/components/index.ts @@ -6,3 +6,6 @@ export { ReviewFindings } from './ReviewFindings'; export { FindingItem } from './FindingItem'; export { FindingsSummary } from './FindingsSummary'; export { SeverityGroupHeader } from './SeverityGroupHeader'; +export { MRFilterBar } from './MRFilterBar'; +export { MRStatusIndicator, CompactMRStatusIndicator } from './StatusIndicator'; +export { MRLogs } from './MRLogs'; diff --git a/apps/desktop/src/renderer/components/gitlab-merge-requests/hooks/index.ts b/apps/desktop/src/renderer/components/gitlab-merge-requests/hooks/index.ts index e6fa4b2d9f..7d54400116 100644 --- a/apps/desktop/src/renderer/components/gitlab-merge-requests/hooks/index.ts +++ b/apps/desktop/src/renderer/components/gitlab-merge-requests/hooks/index.ts @@ -1,2 +1,3 @@ export * from './useGitLabMRs'; export * from './useFindingSelection'; +export * from './useGitLabMRFiltering'; diff --git a/apps/desktop/src/renderer/components/gitlab-merge-requests/hooks/useGitLabMRFiltering.ts b/apps/desktop/src/renderer/components/gitlab-merge-requests/hooks/useGitLabMRFiltering.ts new file mode 100644 index 0000000000..7fd2062241 --- /dev/null +++ b/apps/desktop/src/renderer/components/gitlab-merge-requests/hooks/useGitLabMRFiltering.ts @@ -0,0 +1,251 @@ +/** + * Hook for filtering and searching GitLab MRs + * + * Stub hook - implements the same pattern as usePRFiltering + * adapted for GitLab merge requests. + * + * NOTE: This hook and MRFilterBar are reserved for future filtering functionality. + * They are not currently integrated into the GitLab MRs UI but are retained + * for when filtering/search features are implemented. + * + * TODO: Integrate MRFilterBar into GitLabMergeRequests.tsx by: + * 1. Importing useGitLabMRFiltering hook + * 2. Importing MRFilterBar component from ./components/MRFilterBar + * 3. Adding filter state management to GitLabMergeRequests component + * 4. Passing filtered MRs to MergeRequestList instead of raw mrs + * 5. Adding UI toggle for filter bar visibility + */ + +import { useMemo, useState, useCallback } from 'react'; +import type { + GitLabMergeRequest, + GitLabMRReviewResult, + GitLabMRReviewProgress, + GitLabNewCommitsCheck +} from '@shared/types'; + +export type GitLabMRStatusFilter = + | 'all' + | 'reviewing' + | 'not_reviewed' + | 'reviewed' + | 'posted' + | 'changes_requested' + | 'ready_to_merge' + | 'ready_for_followup'; + +export type GitLabMRSortOption = 'newest' | 'oldest' | 'largest'; + +export interface GitLabMRFilterState { + searchQuery: string; + contributors: string[]; + statuses: GitLabMRStatusFilter[]; + sortBy: GitLabMRSortOption; +} + +interface GitLabMRReviewInfo { + isReviewing: boolean; + result: GitLabMRReviewResult | null; + newCommitsCheck?: GitLabNewCommitsCheck | null; +} + +const DEFAULT_FILTERS: GitLabMRFilterState = { + searchQuery: '', + contributors: [], + statuses: [], + sortBy: 'newest', +}; + +/** + * Determine the computed status of an MR based on its review state + */ +function getMRComputedStatus( + reviewInfo: GitLabMRReviewInfo | null +): GitLabMRStatusFilter { + // Check if currently reviewing (highest priority) + if (reviewInfo?.isReviewing) { + return 'reviewing'; + } + + if (!reviewInfo?.result) { + return 'not_reviewed'; + } + + const result = reviewInfo.result; + const hasPosted = Boolean(result.hasPostedFindings); + // Use overallStatus from review result as source of truth, fallback to severity check + const hasBlockingFindings = + result.overallStatus === 'request_changes' || + result.findings?.some(f => f.severity === 'critical' || f.severity === 'high'); + // Use backend-calculated field that compares commit timestamps against review post time + const hasCommitsAfterPosting = reviewInfo.newCommitsCheck?.hasCommitsAfterPosting ?? false; + + // Check for ready for follow-up first (highest priority after posting) + // Must have new commits that happened AFTER findings were posted + if (hasCommitsAfterPosting) { + return 'ready_for_followup'; + } + + // Posted with blocking findings + if (hasPosted && hasBlockingFindings) { + return 'changes_requested'; + } + + // Posted without blocking findings + if (hasPosted) { + return 'ready_to_merge'; + } + + // Has review result but not posted yet + return 'reviewed'; +} + +export function useGitLabMRFiltering( + mrs: GitLabMergeRequest[], + getReviewStateForMR: (mrIid: number) => { + isReviewing: boolean; + progress: GitLabMRReviewProgress | null; + result: GitLabMRReviewResult | null; + error: string | null; + newCommitsCheck: GitLabNewCommitsCheck | null; + } | null +) { + const [filters, setFiltersState] = useState(DEFAULT_FILTERS); + + // Derive unique contributors from MRs + const contributors = useMemo(() => { + const authorSet = new Set(); + mrs.forEach(mr => { + if (mr.author?.username) { + authorSet.add(mr.author.username); + } + }); + return Array.from(authorSet).sort((a, b) => + a.toLowerCase().localeCompare(b.toLowerCase()) + ); + }, [mrs]); + + // Filter and sort MRs based on current filters + const filteredMRs = useMemo(() => { + const filtered = mrs.filter(mr => { + // Search filter - matches title or description + if (filters.searchQuery) { + const query = filters.searchQuery.toLowerCase(); + const matchesTitle = mr.title.toLowerCase().includes(query); + const matchesDescription = mr.description?.toLowerCase().includes(query); + const matchesIid = mr.iid.toString().includes(query); + if (!matchesTitle && !matchesDescription && !matchesIid) { + return false; + } + } + + // Contributors filter (multi-select) + if (filters.contributors.length > 0) { + const authorUsername = mr.author?.username; + if (!authorUsername || !filters.contributors.includes(authorUsername)) { + return false; + } + } + + // Status filter (multi-select) + if (filters.statuses.length > 0) { + // 'all' acts as a wildcard - include all MRs when 'all' is selected + const activeStatuses = filters.statuses.filter(s => s !== 'all'); + if (activeStatuses.length > 0) { + const reviewInfo = getReviewStateForMR(mr.iid); + const computedStatus = getMRComputedStatus(reviewInfo); + + // Check if MR matches any of the selected statuses + const matchesStatus = activeStatuses.some(status => { + // Special handling: 'posted' should match any posted state + if (status === 'posted') { + const hasPosted = reviewInfo?.result?.hasPostedFindings; + return hasPosted; + } + return computedStatus === status; + }); + + if (!matchesStatus) { + return false; + } + } + } + + return true; + }); + + // Pre-compute timestamps to avoid creating Date objects on every comparison + const timestamps = new Map( + filtered.map((mr) => [mr.iid, new Date(mr.createdAt).getTime()]) + ); + + // Sort the filtered results + return filtered.sort((a, b) => { + const aTime = timestamps.get(a.iid)!; + const bTime = timestamps.get(b.iid)!; + + switch (filters.sortBy) { + case 'newest': + // Sort by createdAt descending (most recent first) + return bTime - aTime; + case 'oldest': + // Sort by createdAt ascending (oldest first) + return aTime - bTime; + case 'largest': { + // Sort by title length as a proxy for complexity (descending) + const aTitleLen = a.title.length; + const bTitleLen = b.title.length; + if (bTitleLen !== aTitleLen) return bTitleLen - aTitleLen; + // Secondary sort by createdAt (newest first) for stable ordering + return bTime - aTime; + } + default: + return 0; + } + }); + }, [mrs, filters, getReviewStateForMR]); + + // Filter setters + const setSearchQuery = useCallback((query: string) => { + setFiltersState(prev => ({ ...prev, searchQuery: query })); + }, []); + + const setContributors = useCallback((selected: string[]) => { + setFiltersState(prev => ({ ...prev, contributors: selected })); + }, []); + + const setStatuses = useCallback((statuses: GitLabMRStatusFilter[]) => { + setFiltersState(prev => ({ ...prev, statuses })); + }, []); + + const setSortBy = useCallback((sortBy: GitLabMRSortOption) => { + setFiltersState(prev => ({ ...prev, sortBy })); + }, []); + + const clearFilters = useCallback(() => { + setFiltersState((prev) => ({ + ...DEFAULT_FILTERS, + sortBy: prev.sortBy, // Preserve sort preference when clearing filters + })); + }, []); + + const hasActiveFilters = useMemo(() => { + return ( + filters.searchQuery !== '' || + filters.contributors.length > 0 || + filters.statuses.length > 0 + ); + }, [filters]); + + return { + filteredMRs, + contributors, + filters, + setSearchQuery, + setContributors, + setStatuses, + setSortBy, + clearFilters, + hasActiveFilters, + }; +} diff --git a/apps/desktop/src/renderer/components/gitlab-merge-requests/hooks/useGitLabMRs.ts b/apps/desktop/src/renderer/components/gitlab-merge-requests/hooks/useGitLabMRs.ts index 000fc47d39..0407c32493 100644 --- a/apps/desktop/src/renderer/components/gitlab-merge-requests/hooks/useGitLabMRs.ts +++ b/apps/desktop/src/renderer/components/gitlab-merge-requests/hooks/useGitLabMRs.ts @@ -50,6 +50,15 @@ interface UseGitLabMRsResult { error: string | null; newCommitsCheck: GitLabNewCommitsCheck | null; } | null; + listMoreMRs: (page?: number) => Promise; + deleteReview: (mrIid: number, noteId: number) => Promise; + checkMergeReadiness: (mrIid: number) => Promise<{ + canMerge: boolean; + hasConflicts: boolean; + needsDiscussion: boolean; + pipelineStatus?: string; + } | null>; + getLogs: (mrIid: number) => Promise; } export function useGitLabMRs(projectId?: string, options: UseGitLabMRsOptions = {}): UseGitLabMRsResult { @@ -62,7 +71,6 @@ export function useGitLabMRs(projectId?: string, options: UseGitLabMRsOptions = const [projectPath, setProjectPath] = useState(null); // Get MR review state from the global store - const _mrReviews = useMRReviewStore((state) => state.mrReviews); const getMRReviewState = useMRReviewStore((state) => state.getMRReviewState); const getActiveMRReviews = useMRReviewStore((state) => state.getActiveMRReviews); @@ -279,6 +287,77 @@ export function useGitLabMRs(projectId?: string, options: UseGitLabMRsOptions = } }, [projectId]); + // NEW: Additional methods for feature parity + const listMoreMRs = useCallback(async (page: number = 2): Promise => { + if (!projectId || !window.electronAPI.listMoreGitLabMRs) return false; + + try { + const result = await window.electronAPI.listMoreGitLabMRs(projectId, stateFilter, page); + if (result.success && result.data) { + const { mrs, hasMore } = result.data; + setMergeRequests(prev => [...prev, ...mrs]); + return hasMore; + } + setError(result.error || 'Failed to load more MRs'); + return false; + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load more MRs'); + return false; + } + }, [projectId, stateFilter]); + + const deleteReview = useCallback(async (mrIid: number, noteId: number): Promise => { + if (!projectId || !window.electronAPI.deleteGitLabMRReview) return false; + + try { + const result = await window.electronAPI.deleteGitLabMRReview(projectId, mrIid, noteId); + if (result.success && result.data?.deleted) { + // Clear review from store + useMRReviewStore.getState().clearMRReview(projectId, mrIid); + return true; + } + if (!result.success) { + setError(result.error || 'Failed to delete review'); + } + return false; + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to delete review'); + return false; + } + }, [projectId]); + + const checkMergeReadiness = useCallback(async (mrIid: number) => { + if (!projectId || !window.electronAPI.checkGitLabMRMergeReadiness) return null; + + try { + const result = await window.electronAPI.checkGitLabMRMergeReadiness(projectId, mrIid); + if (!result.success) { + setError(result.error || 'Failed to check merge readiness'); + return null; + } + return result.data ?? null; + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to check merge readiness'); + return null; + } + }, [projectId]); + + const getLogs = useCallback(async (mrIid: number): Promise => { + if (!projectId || !window.electronAPI.getGitLabMRLogs) return null; + + try { + const result = await window.electronAPI.getGitLabMRLogs(projectId, mrIid); + if (!result.success) { + setError(result.error || 'Failed to get logs'); + return null; + } + return result.data ?? null; + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to get logs'); + return null; + } + }, [projectId]); + return { mergeRequests, isLoading, @@ -303,5 +382,9 @@ export function useGitLabMRs(projectId?: string, options: UseGitLabMRsOptions = assignMR, approveMR, getReviewStateForMR, + listMoreMRs, + deleteReview, + checkMergeReadiness, + getLogs, }; } diff --git a/apps/desktop/src/renderer/components/onboarding/GraphitiStep.tsx b/apps/desktop/src/renderer/components/onboarding/GraphitiStep.tsx index e4be1ff1c7..3573dcb631 100644 --- a/apps/desktop/src/renderer/components/onboarding/GraphitiStep.tsx +++ b/apps/desktop/src/renderer/components/onboarding/GraphitiStep.tsx @@ -156,7 +156,12 @@ export function GraphitiStep({ onNext, onBack, onSkip }: GraphitiStepProps) { setIsCheckingInfra(true); try { const result = await window.electronAPI.getMemoryInfrastructureStatus(); - setKuzuAvailable(!!(result?.success && result?.data?.memory?.kuzuInstalled )); + if (result?.success && result?.data && typeof result.data === 'object') { + const data = result.data as { memory?: { kuzuInstalled?: boolean } }; + setKuzuAvailable(!!data.memory?.kuzuInstalled); + } else { + setKuzuAvailable(false); + } } catch { setKuzuAvailable(false); } finally { @@ -233,28 +238,18 @@ export function GraphitiStep({ onNext, onBack, onSkip }: GraphitiStepProps) { setValidationStatus({ database: null, provider: null }); try { - // Get the API key for the current LLM provider - const apiKey = config.llmProvider === 'openai' ? config.openaiApiKey : - config.llmProvider === 'anthropic' ? config.anthropicApiKey : - config.llmProvider === 'google' ? config.googleApiKey : - config.llmProvider === 'groq' ? config.groqApiKey : - config.llmProvider === 'openrouter' ? config.openrouterApiKey : - config.llmProvider === 'azure_openai' ? config.azureOpenaiApiKey : - config.llmProvider === 'ollama' ? '' : // Ollama doesn't need API key - config.embeddingProvider === 'openai' ? config.openaiApiKey : - config.embeddingProvider === 'openrouter' ? config.openrouterApiKey : ''; - const result = await window.electronAPI.testMemoryConnection( config.dbPath || undefined, config.database || 'auto_claude_memory' ); - if (result?.success && result?.data) { + if (result?.success && result?.data && typeof result.data === 'object') { + const data = result.data as { success?: boolean; message?: string }; setValidationStatus({ database: { tested: true, - success: result.data.success, - message: result.data.message + success: data.success ?? false, + message: data.message || 'Connection test completed' }, provider: { tested: true, @@ -263,8 +258,8 @@ export function GraphitiStep({ onNext, onBack, onSkip }: GraphitiStepProps) { } }); - if (!result.data.success) { - setError(`Database: ${result.data.message}`); + if (data.success === false) { + setError(`Database: ${data.message || 'Connection failed'}`); } } else { setError(result?.error || 'Failed to test connection'); diff --git a/apps/desktop/src/renderer/lib/browser-mock.ts b/apps/desktop/src/renderer/lib/browser-mock.ts index 5259afd86c..bde6f568be 100644 --- a/apps/desktop/src/renderer/lib/browser-mock.ts +++ b/apps/desktop/src/renderer/lib/browser-mock.ts @@ -27,8 +27,12 @@ const isElectron = typeof window !== 'undefined' && window.electronAPI !== undef /** * Create mock electronAPI for browser * Aggregates all mock implementations from separate modules + * + * Note: This mock is used for UI development in a browser environment and doesn't + * need to implement every single API method. The type assertion is necessary + * because the mock is intentionally incomplete. */ -const browserMockAPI: ElectronAPI = { +const browserMockAPI = { // Project Operations ...projectMock, @@ -318,7 +322,11 @@ const browserMockAPI: ElectronAPI = { startStatusPolling: async () => true, stopStatusPolling: async () => true, getPollingMetadata: async () => null, - onPRStatusUpdate: () => () => {} + onPRStatusUpdate: () => () => {}, + // Release operations (changelog-based) + getReleaseableVersions: async () => ({ success: true, data: [] }), + runReleasePreflightCheck: async () => ({ success: true, data: { canRelease: true, checks: [], blockers: [] } }), + createRelease: async () => ({ success: true, data: { url: '' } }) }, // Queue Routing API (rate limit recovery) @@ -452,7 +460,7 @@ const browserMockAPI: ElectronAPI = { copyDebugInfo: async () => ({ success: false, error: 'Not available in browser mode' }), getRecentErrors: async () => [], listLogFiles: async () => [] -}; +} satisfies Partial; /** * Initialize browser mock if not running in Electron @@ -460,7 +468,10 @@ const browserMockAPI: ElectronAPI = { export function initBrowserMock(): void { if (!isElectron) { console.warn('%c[Browser Mock] Initializing mock electronAPI for browser preview', 'color: #f0ad4e; font-weight: bold;'); - (window as Window & { electronAPI: ElectronAPI }).electronAPI = browserMockAPI; + // Type assertion: browser mock is used for UI development in browser + // and doesn't need to implement every single API method + // Cast through unknown since we're using Partial for type safety + (window as Window & { electronAPI: ElectronAPI }).electronAPI = browserMockAPI as unknown as ElectronAPI; } } diff --git a/apps/desktop/src/renderer/lib/mocks/changelog-mock.ts b/apps/desktop/src/renderer/lib/mocks/changelog-mock.ts index 36501b2e77..8d2030b2e5 100644 --- a/apps/desktop/src/renderer/lib/mocks/changelog-mock.ts +++ b/apps/desktop/src/renderer/lib/mocks/changelog-mock.ts @@ -134,8 +134,9 @@ export const changelogMock = { } }), - createRelease: () => { + createRelease: async () => { console.warn('[Browser Mock] createRelease called'); + return { success: true, data: { url: '', tagName: '' } }; }, onReleaseProgress: () => () => {}, diff --git a/apps/desktop/src/renderer/lib/mocks/project-mock.ts b/apps/desktop/src/renderer/lib/mocks/project-mock.ts index 153600e098..4f9491fc13 100644 --- a/apps/desktop/src/renderer/lib/mocks/project-mock.ts +++ b/apps/desktop/src/renderer/lib/mocks/project-mock.ts @@ -125,5 +125,33 @@ export const projectMock = { initializeGit: async () => ({ success: true, data: { success: true } + }), + + // Memory Infrastructure operations (LadybugDB) + getMemoryInfrastructureStatus: async () => ({ + success: true, + data: { + memory: { + kuzuInstalled: true, + databasePath: '~/.auto-claude/graphs', + databaseExists: true, + databases: ['auto_claude_memory'] + }, + ready: true + } + }), + + listMemoryDatabases: async () => ({ + success: true, + data: ['auto_claude_memory', 'project_memory'] + }), + + testMemoryConnection: async () => ({ + success: true, + data: { + success: true, + message: 'Connected to LadybugDB database (mock)', + details: { latencyMs: 5 } + } }) }; diff --git a/apps/desktop/src/renderer/lib/mocks/terminal-mock.ts b/apps/desktop/src/renderer/lib/mocks/terminal-mock.ts index def8deef5d..05dc955ed7 100644 --- a/apps/desktop/src/renderer/lib/mocks/terminal-mock.ts +++ b/apps/desktop/src/renderer/lib/mocks/terminal-mock.ts @@ -82,7 +82,7 @@ export const terminalMock = { } }), - saveTerminalBuffer: async () => {}, + saveTerminalBuffer: async (_terminalId: string, _serializedBuffer: string) => ({ success: true }), checkTerminalPtyAlive: async () => ({ success: true, diff --git a/apps/desktop/src/renderer/stores/context-store.ts b/apps/desktop/src/renderer/stores/context-store.ts index f18ae2d21a..b16c2294c7 100644 --- a/apps/desktop/src/renderer/stores/context-store.ts +++ b/apps/desktop/src/renderer/stores/context-store.ts @@ -107,11 +107,28 @@ export async function loadProjectContext(projectId: string): Promise { try { const result = await window.electronAPI.getProjectContext(projectId); - if (result.success && result.data) { - store.setProjectIndex(result.data.projectIndex); - store.setMemoryStatus(result.data.memoryStatus); - store.setMemoryState(result.data.memoryState); - store.setRecentMemories(result.data.recentMemories || []); + if (result.success && result.data && typeof result.data === 'object') { + const data = result.data as { + projectIndex?: unknown; + memoryStatus?: unknown; + memoryState?: unknown; + recentMemories?: unknown; + }; + if (data.projectIndex && typeof data.projectIndex === 'object') { + store.setProjectIndex(data.projectIndex as ProjectIndex); + } + if (data.memoryStatus && typeof data.memoryStatus === 'object') { + store.setMemoryStatus(data.memoryStatus as MemorySystemStatus); + } + if (data.memoryState && typeof data.memoryState === 'object') { + store.setMemoryState(data.memoryState as MemorySystemState); + } + if (Array.isArray(data.recentMemories)) { + store.setRecentMemories(data.recentMemories as RendererMemory[]); + } else if (result.success) { + // Unexpected data shape - clear to avoid stale data + store.setRecentMemories([]); + } } else { store.setIndexError(result.error || 'Failed to load project context'); } @@ -133,8 +150,8 @@ export async function refreshProjectIndex(projectId: string): Promise { try { const result = await window.electronAPI.refreshProjectIndex(projectId); - if (result.success && result.data) { - store.setProjectIndex(result.data); + if (result.success && result.data && typeof result.data === 'object') { + store.setProjectIndex(result.data as ProjectIndex); } else { store.setIndexError(result.error || 'Failed to refresh project index'); } @@ -164,8 +181,8 @@ export async function searchMemories( try { const result = await window.electronAPI.searchMemories(projectId, query); - if (result.success && result.data) { - store.setSearchResults(result.data); + if (result.success && result.data && Array.isArray(result.data)) { + store.setSearchResults(result.data as ContextSearchResult[]); } else { store.setSearchResults([]); } @@ -188,8 +205,8 @@ export async function loadRecentMemories( try { const result = await window.electronAPI.getRecentMemories(projectId, limit); - if (result.success && result.data) { - store.setRecentMemories(result.data); + if (result.success && result.data && Array.isArray(result.data)) { + store.setRecentMemories(result.data as RendererMemory[]); } } catch (_error) { // Silently fail - memories are optional diff --git a/apps/desktop/src/renderer/stores/gitlab/__tests__/investigation-store.test.ts b/apps/desktop/src/renderer/stores/gitlab/__tests__/investigation-store.test.ts new file mode 100644 index 0000000000..9028d9d659 --- /dev/null +++ b/apps/desktop/src/renderer/stores/gitlab/__tests__/investigation-store.test.ts @@ -0,0 +1,81 @@ +/** + * Unit tests for GitLab investigation store + */ +import { describe, it, expect, beforeEach } from 'vitest'; +import { useInvestigationStore } from '../investigation-store'; +import type { GitLabInvestigationStatus, GitLabInvestigationResult } from '@shared/types'; + +describe('investigation-store', () => { + beforeEach(() => { + useInvestigationStore.getState().clearInvestigation(); + }); + + it('should initialize with idle state', () => { + const state = useInvestigationStore.getState(); + expect(state.investigationStatus.phase).toBe('idle'); + expect(state.lastInvestigationResult).toBe(null); + }); + + it('should set investigation status', () => { + const status: GitLabInvestigationStatus = { + phase: 'fetching', + issueIid: 1, + progress: 25, + message: 'Fetching issue...' + }; + useInvestigationStore.getState().setInvestigationStatus(status); + expect(useInvestigationStore.getState().investigationStatus.phase).toBe('fetching'); + }); + + it('should set investigation result', () => { + const result: GitLabInvestigationResult = { + success: true, + issueIid: 1, + analysis: { + summary: 'Test summary', + proposedSolution: 'Fix the bug', + affectedFiles: ['file.ts'], + estimatedComplexity: 'simple', + acceptanceCriteria: ['Test passes'] + } + }; + useInvestigationStore.getState().setInvestigationResult(result); + expect(useInvestigationStore.getState().lastInvestigationResult?.issueIid).toBe(1); + }); + + it('should clear investigation', () => { + useInvestigationStore.getState().setInvestigationStatus({ + phase: 'fetching', + progress: 50, + message: 'Testing' + }); + useInvestigationStore.getState().clearInvestigation(); + + const state = useInvestigationStore.getState(); + expect(state.investigationStatus.phase).toBe('idle'); + expect(state.lastInvestigationResult).toBe(null); + }); + + it('should handle error phase', () => { + const status: GitLabInvestigationStatus = { + phase: 'error', + issueIid: 1, + progress: 0, + message: 'Investigation failed', + error: 'Network error' + }; + useInvestigationStore.getState().setInvestigationStatus(status); + expect(useInvestigationStore.getState().investigationStatus.phase).toBe('error'); + }); + + it('should handle creating_task phase', () => { + const status: GitLabInvestigationStatus = { + phase: 'creating_task', + issueIid: 1, + progress: 80, + message: 'Creating task...' + }; + useInvestigationStore.getState().setInvestigationStatus(status); + expect(useInvestigationStore.getState().investigationStatus.phase).toBe('creating_task'); + }); +}); diff --git a/apps/desktop/src/renderer/stores/gitlab/__tests__/issues-store.test.ts b/apps/desktop/src/renderer/stores/gitlab/__tests__/issues-store.test.ts new file mode 100644 index 0000000000..ab59f8ab78 --- /dev/null +++ b/apps/desktop/src/renderer/stores/gitlab/__tests__/issues-store.test.ts @@ -0,0 +1,109 @@ +/** + * Unit tests for GitLab issues store + */ +import { describe, it, expect, beforeEach } from 'vitest'; +import { useIssuesStore } from '../issues-store'; +import type { GitLabIssue } from '@shared/types'; + +/** + * Mock factory for GitLab issues + * Follows existing codebase pattern from github-prs/hooks/__tests__ + */ +function createMockGitLabIssue(overrides: Partial = {}): GitLabIssue { + return { + id: 1, + iid: 1, + title: 'Test Issue', + description: 'Test description', + state: 'opened', + labels: [], + assignees: [], + author: { username: 'testuser', avatarUrl: '' }, + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + webUrl: 'https://gitlab.com/test/project/issues/1', + projectPathWithNamespace: 'test/project', + userNotesCount: 0, + ...overrides, + }; +} + +describe('issues-store', () => { + beforeEach(() => { + useIssuesStore.getState().clearIssues(); + }); + + it('should initialize with empty state', () => { + const state = useIssuesStore.getState(); + expect(state.issues).toEqual([]); + expect(state.isLoading).toBe(false); + expect(state.error).toBe(null); + }); + + it('should set issues', () => { + const issues = [createMockGitLabIssue({ iid: 1, title: 'Test' })]; + useIssuesStore.getState().setIssues(issues); + expect(useIssuesStore.getState().issues).toHaveLength(1); + }); + + it('should replace issues with new array', () => { + const issue1 = createMockGitLabIssue({ iid: 1, title: 'Test 1' }); + const issue2 = createMockGitLabIssue({ iid: 2, title: 'Test 2' }); + + useIssuesStore.getState().setIssues([issue1]); + useIssuesStore.getState().setIssues([...useIssuesStore.getState().issues, issue2]); + + expect(useIssuesStore.getState().issues).toHaveLength(2); + }); + + it('should update issue', () => { + const issue = createMockGitLabIssue({ iid: 1, state: 'opened' }); + useIssuesStore.getState().setIssues([issue]); + useIssuesStore.getState().updateIssue(1, { state: 'closed' }); + + const updated = useIssuesStore.getState().issues[0]; + expect(updated.state).toBe('closed'); + }); + + it('should get filtered issues', () => { + const issues = [ + createMockGitLabIssue({ iid: 1, state: 'opened' }), + createMockGitLabIssue({ iid: 2, state: 'closed' }), + createMockGitLabIssue({ iid: 3, state: 'opened' }), + ]; + useIssuesStore.getState().setIssues(issues); + useIssuesStore.getState().setFilterState('opened'); + + const filtered = useIssuesStore.getState().getFilteredIssues(); + expect(filtered).toHaveLength(2); + expect(filtered.every((i: GitLabIssue) => i.state === 'opened')).toBe(true); + }); + + it('should get selected issue', () => { + const issue = createMockGitLabIssue({ iid: 1, title: 'Test' }); + useIssuesStore.getState().setIssues([issue]); + useIssuesStore.getState().selectIssue(1); + + const selected = useIssuesStore.getState().getSelectedIssue(); + expect(selected?.iid).toBe(1); + }); + + it('should count open issues', () => { + const issues = [ + createMockGitLabIssue({ iid: 1, state: 'opened' }), + createMockGitLabIssue({ iid: 2, state: 'closed' }), + createMockGitLabIssue({ iid: 3, state: 'opened' }), + ]; + useIssuesStore.getState().setIssues(issues); + + expect(useIssuesStore.getState().getOpenIssuesCount()).toBe(2); + }); + + it('should reset selection', () => { + useIssuesStore.getState().selectIssue(1); + expect(useIssuesStore.getState().selectedIssueIid).toBe(1); + + useIssuesStore.getState().selectIssue(null); + expect(useIssuesStore.getState().selectedIssueIid).toBe(null); + }); +}); diff --git a/apps/desktop/src/renderer/stores/gitlab/__tests__/sync-status-store.test.ts b/apps/desktop/src/renderer/stores/gitlab/__tests__/sync-status-store.test.ts new file mode 100644 index 0000000000..b4263dbf97 --- /dev/null +++ b/apps/desktop/src/renderer/stores/gitlab/__tests__/sync-status-store.test.ts @@ -0,0 +1,138 @@ +/** + * Unit tests for GitLab sync status store + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { useSyncStatusStore, checkGitLabConnection } from '../sync-status-store'; +import type { GitLabSyncStatus } from '@shared/types'; + +// Mock electronAPI +const mockElectronAPI = { + checkGitLabConnection: vi.fn() +}; + +describe('sync-status-store', () => { + beforeEach(() => { + vi.stubGlobal('window', { + ...(globalThis.window ?? {}), + electronAPI: mockElectronAPI + } as unknown as Window & typeof globalThis); + useSyncStatusStore.getState().clearSyncStatus(); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('should initialize with empty state', () => { + const state = useSyncStatusStore.getState(); + expect(state.syncStatus).toBe(null); + expect(state.connectionError).toBe(null); + }); + + it('should set sync status', () => { + const status: GitLabSyncStatus = { + connected: true, + projectPathWithNamespace: 'group/project' + }; + useSyncStatusStore.getState().setSyncStatus(status); + expect(useSyncStatusStore.getState().syncStatus).toEqual(status); + }); + + it('should check connection status', () => { + useSyncStatusStore.getState().setSyncStatus({ + connected: true, + projectPathWithNamespace: 'group/project' + }); + expect(useSyncStatusStore.getState().isConnected()).toBe(true); + expect(useSyncStatusStore.getState().getProjectPath()).toBe('group/project'); + }); + + it('should handle disconnected state', () => { + useSyncStatusStore.getState().setSyncStatus({ + connected: false, + projectPathWithNamespace: undefined + }); + expect(useSyncStatusStore.getState().isConnected()).toBe(false); + expect(useSyncStatusStore.getState().getProjectPath()).toBe(null); + }); + + it('should set connection error', () => { + useSyncStatusStore.getState().setConnectionError('Connection failed'); + expect(useSyncStatusStore.getState().connectionError).toBe('Connection failed'); + }); + + it('should clear sync status', () => { + useSyncStatusStore.getState().setSyncStatus({ + connected: true, + projectPathWithNamespace: 'group/project' + }); + useSyncStatusStore.getState().clearSyncStatus(); + + expect(useSyncStatusStore.getState().syncStatus).toBe(null); + expect(useSyncStatusStore.getState().connectionError).toBe(null); + }); + + describe('checkGitLabConnection', () => { + it('should update store on successful connection', async () => { + mockElectronAPI.checkGitLabConnection.mockResolvedValue({ + success: true, + data: { + connected: true, + projectPathWithNamespace: 'group/project' + } + }); + + const result = await checkGitLabConnection('project-123'); + + expect(result).toEqual({ + connected: true, + projectPathWithNamespace: 'group/project' + }); + expect(useSyncStatusStore.getState().syncStatus).toEqual({ + connected: true, + projectPathWithNamespace: 'group/project' + }); + expect(useSyncStatusStore.getState().connectionError).toBe(null); + }); + + it('should set error on failed connection', async () => { + mockElectronAPI.checkGitLabConnection.mockResolvedValue({ + success: false, + error: 'Authentication failed' + }); + + const result = await checkGitLabConnection('project-123'); + + expect(result).toBe(null); + expect(useSyncStatusStore.getState().syncStatus).toBe(null); + expect(useSyncStatusStore.getState().connectionError).toBe('Authentication failed'); + }); + + it('should set error when connected is false', async () => { + mockElectronAPI.checkGitLabConnection.mockResolvedValue({ + success: true, + data: { + connected: false, + error: 'Project not found' + } + }); + + const result = await checkGitLabConnection('project-123'); + + expect(result).toBe(null); + expect(useSyncStatusStore.getState().syncStatus).toBe(null); + expect(useSyncStatusStore.getState().connectionError).toBe('Project not found'); + }); + + it('should set error on exception', async () => { + mockElectronAPI.checkGitLabConnection.mockRejectedValue(new Error('Network error')); + + const result = await checkGitLabConnection('project-123'); + + expect(result).toBe(null); + expect(useSyncStatusStore.getState().syncStatus).toBe(null); + expect(useSyncStatusStore.getState().connectionError).toBe('Network error'); + }); + }); +}); diff --git a/apps/desktop/src/renderer/stores/gitlab/index.ts b/apps/desktop/src/renderer/stores/gitlab/index.ts index 3e80f471f7..9bd45b8e51 100644 --- a/apps/desktop/src/renderer/stores/gitlab/index.ts +++ b/apps/desktop/src/renderer/stores/gitlab/index.ts @@ -4,14 +4,38 @@ * This module exports all GitLab-related stores and their utilities. */ +// Issues Store +export { + useIssuesStore, + loadGitLabIssues, + importGitLabIssues, + type IssueFilterState +} from './issues-store'; + // MR Review Store export { useMRReviewStore, initializeMRReviewListeners, + cleanupMRReviewListeners, startMRReview, startFollowupReview } from './mr-review-store'; -import { initializeMRReviewListeners as _initMRReviewListeners } from './mr-review-store'; +import { + initializeMRReviewListeners as _initMRReviewListeners, + cleanupMRReviewListeners as _cleanupMRReviewListeners +} from './mr-review-store'; + +// Investigation Store +export { + useInvestigationStore, + investigateGitLabIssue +} from './investigation-store'; + +// Sync Status Store +export { + useSyncStatusStore, + checkGitLabConnection +} from './sync-status-store'; /** * Initialize all global GitLab listeners. @@ -22,6 +46,15 @@ export function initializeGitLabListeners(): void { // Add other global listeners here as needed } +/** + * Cleanup all global GitLab listeners. + * Call this during app unmount or hot-reload. + */ +export function cleanupGitLabListeners(): void { + _cleanupMRReviewListeners(); + // Add other cleanup implementations here as needed +} + // Re-export types for convenience export type { GitLabMRReviewProgress, @@ -30,5 +63,6 @@ export type { GitLabMergeRequest, GitLabSyncStatus, GitLabInvestigationStatus, - GitLabInvestigationResult + GitLabInvestigationResult, + GitLabIssue } from '../../../shared/types'; diff --git a/apps/desktop/src/renderer/stores/gitlab/investigation-store.ts b/apps/desktop/src/renderer/stores/gitlab/investigation-store.ts new file mode 100644 index 0000000000..8c1045aee4 --- /dev/null +++ b/apps/desktop/src/renderer/stores/gitlab/investigation-store.ts @@ -0,0 +1,62 @@ +/** + * GitLab Investigation Store + * + * Tracks investigation state for GitLab issues. + * Mirrors github investigation patterns. + */ +import { create } from 'zustand'; +import type { + GitLabInvestigationStatus, + GitLabInvestigationResult +} from '@shared/types'; + +interface InvestigationState { + // Investigation state + investigationStatus: GitLabInvestigationStatus; + lastInvestigationResult: GitLabInvestigationResult | null; + + // Actions + setInvestigationStatus: (status: GitLabInvestigationStatus) => void; + setInvestigationResult: (result: GitLabInvestigationResult | null) => void; + clearInvestigation: () => void; +} + +export const useInvestigationStore = create((set) => ({ + // Initial state + investigationStatus: { + phase: 'idle', + progress: 0, + message: '' + }, + lastInvestigationResult: null, + + // Actions + setInvestigationStatus: (investigationStatus) => set({ investigationStatus }), + + setInvestigationResult: (lastInvestigationResult) => set({ lastInvestigationResult }), + + clearInvestigation: () => set({ + investigationStatus: { phase: 'idle', progress: 0, message: '' }, + lastInvestigationResult: null + }) +})); + +/** + * Start investigating a GitLab issue + */ +export function investigateGitLabIssue( + projectId: string, + issueIid: number, + selectedNoteIds?: number[] +): void { + const store = useInvestigationStore.getState(); + store.setInvestigationStatus({ + phase: 'fetching', + issueIid, + progress: 0, + message: 'Starting investigation...' + }); + store.setInvestigationResult(null); + + window.electronAPI.investigateGitLabIssue(projectId, issueIid, selectedNoteIds); +} diff --git a/apps/desktop/src/renderer/stores/gitlab/issues-store.ts b/apps/desktop/src/renderer/stores/gitlab/issues-store.ts new file mode 100644 index 0000000000..f9df38b819 --- /dev/null +++ b/apps/desktop/src/renderer/stores/gitlab/issues-store.ts @@ -0,0 +1,173 @@ +/** + * GitLab Issues Store + * + * Manages GitLab issue state with filtering. + * Mirrors the structure of github/issues-store.ts. + * + * Note: Pagination support will be added with IPC handlers (Task 8) + */ +import { create } from 'zustand'; +import type { GitLabIssue } from '@shared/types'; +import type { GitLabFilterState } from '@shared/integrations/types/base-types'; + +// GitLab issues don't have 'merged' state (only MRs do), so create a specific type +export type IssueFilterState = Exclude; + +interface IssuesState { + // Data + issues: GitLabIssue[]; + + // UI State + isLoading: boolean; + error: string | null; + selectedIssueIid: number | null; + filterState: IssueFilterState; + currentRequestToken: string | null; + + // Actions + setIssues: (issues: GitLabIssue[]) => void; + addIssue: (issue: GitLabIssue) => void; + updateIssue: (issueIid: number, updates: Partial) => void; + setLoading: (loading: boolean) => void; + setError: (error: string | null) => void; + selectIssue: (issueIid: number | null) => void; + setFilterState: (state: IssueFilterState) => void; + clearIssues: () => void; + setCurrentRequestToken: (token: string | null) => void; + + // Selectors + getSelectedIssue: () => GitLabIssue | null; + getFilteredIssues: () => GitLabIssue[]; + getOpenIssuesCount: () => number; +} + +export const useIssuesStore = create((set, get) => ({ + // Initial state + issues: [], + isLoading: false, + error: null, + selectedIssueIid: null, + filterState: 'opened', + currentRequestToken: null, + + // Actions + setIssues: (issues) => set({ issues, error: null }), + + addIssue: (issue) => set((state) => ({ + issues: [issue, ...state.issues.filter(i => i.iid !== issue.iid)] + })), + + updateIssue: (issueIid, updates) => set((state) => ({ + issues: state.issues.map(issue => + issue.iid === issueIid ? { ...issue, ...updates } : issue + ) + })), + + setLoading: (isLoading) => set({ isLoading }), + + setError: (error) => set({ error }), + + selectIssue: (selectedIssueIid) => set({ selectedIssueIid }), + + setFilterState: (filterState) => set({ filterState }), + + clearIssues: () => set({ + issues: [], + selectedIssueIid: null, + error: null, + currentRequestToken: null, + isLoading: false + }), + + setCurrentRequestToken: (currentRequestToken) => set({ currentRequestToken }), + + // Selectors + getSelectedIssue: () => { + const { issues, selectedIssueIid } = get(); + return issues.find(i => i.iid === selectedIssueIid) || null; + }, + + getFilteredIssues: () => { + const { issues, filterState } = get(); + if (filterState === 'all') return issues; + return issues.filter(issue => issue.state === filterState); + }, + + getOpenIssuesCount: () => { + const { issues } = get(); + return issues.filter(issue => issue.state === 'opened').length; + } +})); + +/** + * Load GitLab issues for a project + */ +export async function loadGitLabIssues( + projectId: string, + state?: IssueFilterState +): Promise { + const requestId = Math.random().toString(36); + const store = useIssuesStore.getState(); + store.setCurrentRequestToken(requestId); + store.setLoading(true); + store.setError(null); + + // Sync filterState with the requested state + if (state) { + store.setFilterState(state); + } + + try { + const result = await window.electronAPI.getGitLabIssues(projectId, state); + + // Guard against stale responses - read live state, not captured store reference + if (useIssuesStore.getState().currentRequestToken !== requestId) { + return; // A newer request has superseded this one + } + + if (result.success && result.data) { + store.setIssues(result.data); + } else { + store.setError(result.error || 'Failed to load GitLab issues'); + } + } catch (error) { + // Guard against stale responses in error case - read live state + if (useIssuesStore.getState().currentRequestToken !== requestId) { + return; + } + store.setError(error instanceof Error ? error.message : 'Unknown error'); + } finally { + // Only clear loading state if this is still the current request - read live state + if (useIssuesStore.getState().currentRequestToken === requestId) { + store.setLoading(false); + } + } +} + +/** + * Import GitLab issues as tasks + */ +export async function importGitLabIssues( + projectId: string, + issueIids: number[] +): Promise { + const store = useIssuesStore.getState(); + store.setLoading(true); + store.setError(null); // Clear previous errors + + try { + const result = await window.electronAPI.importGitLabIssues(projectId, issueIids); + if (result.success) { + store.setError(null); // Clear error on success + return true; + } else { + store.setError(result.error || 'Failed to import GitLab issues'); + return false; + } + } catch (error) { + store.setError(error instanceof Error ? error.message : 'Unknown error'); + return false; + } finally { + store.setLoading(false); + } +} diff --git a/apps/desktop/src/renderer/stores/gitlab/sync-status-store.ts b/apps/desktop/src/renderer/stores/gitlab/sync-status-store.ts new file mode 100644 index 0000000000..9500230ff6 --- /dev/null +++ b/apps/desktop/src/renderer/stores/gitlab/sync-status-store.ts @@ -0,0 +1,87 @@ +/** + * GitLab Sync Status Store + * + * Tracks GitLab connection status for a project. + * Mirrors github sync status patterns. + */ +import { create } from 'zustand'; +import type { GitLabSyncStatus } from '@shared/types'; + +interface SyncStatusState { + // Sync status + syncStatus: GitLabSyncStatus | null; + connectionError: string | null; + + // Actions + setSyncStatus: (status: GitLabSyncStatus | null) => void; + setConnectionError: (error: string | null) => void; + clearSyncStatus: () => void; + + // Selectors + isConnected: () => boolean; + getProjectPath: () => string | null; // Returns projectPathWithNamespace +} + +export const useSyncStatusStore = create((set, get) => ({ + // Initial state + syncStatus: null, + connectionError: null, + + // Actions + setSyncStatus: (syncStatus) => set({ syncStatus, connectionError: null }), + + setConnectionError: (connectionError) => set({ connectionError }), + + clearSyncStatus: () => set({ + syncStatus: null, + connectionError: null + }), + + // Selectors + isConnected: () => { + const { syncStatus } = get(); + return syncStatus?.connected ?? false; + }, + + getProjectPath: () => { + const { syncStatus } = get(); + return syncStatus?.projectPathWithNamespace ?? null; + } +})); + +/** + * Check GitLab connection status + */ +let latestConnectionRequestId = 0; + +export async function checkGitLabConnection(projectId: string): Promise { + const store = useSyncStatusStore.getState(); + const requestId = ++latestConnectionRequestId; + + try { + const result = await window.electronAPI.checkGitLabConnection(projectId); + // Ignore stale responses + if (requestId !== latestConnectionRequestId) return null; + + // Only set sync status if actually connected (connected === true) + if (result.success && result.data && result.data.connected === true) { + store.setSyncStatus(result.data); + return result.data; + } else if (result.success && result.data && result.data.connected === false) { + // Connection failed but request succeeded - treat as error + store.clearSyncStatus(); + store.setConnectionError(result.data.error || 'Failed to check GitLab connection'); + return null; + } else { + store.clearSyncStatus(); + store.setConnectionError(result.error || 'Failed to check GitLab connection'); + return null; + } + } catch (error) { + // Ignore stale responses + if (requestId !== latestConnectionRequestId) return null; + store.clearSyncStatus(); + store.setConnectionError(error instanceof Error ? error.message : 'Unknown error'); + return null; + } +} diff --git a/apps/desktop/src/renderer/stores/release-store.ts b/apps/desktop/src/renderer/stores/release-store.ts index f3e8fcf704..919f73dee5 100644 --- a/apps/desktop/src/renderer/stores/release-store.ts +++ b/apps/desktop/src/renderer/stores/release-store.ts @@ -95,12 +95,13 @@ export async function loadReleaseableVersions(projectId: string): Promise try { const result = await window.electronAPI.getReleaseableVersions(projectId); - if (result.success && result.data) { - store.setReleaseableVersions(result.data); + if (result.success && result.data && Array.isArray(result.data)) { + const versions = result.data as ReleaseableVersion[]; + store.setReleaseableVersions(versions); // Auto-select first unreleased version if none selected if (!store.selectedVersion) { - const firstUnreleased = result.data.find((v: ReleaseableVersion) => !v.isReleased); + const firstUnreleased = versions.find((v: ReleaseableVersion) => !v.isReleased); if (firstUnreleased) { store.setSelectedVersion(firstUnreleased.version); } @@ -132,8 +133,8 @@ export async function runPreflightCheck(projectId: string): Promise { try { const result = await window.electronAPI.runReleasePreflightCheck(projectId, version); - if (result.success && result.data) { - store.setPreflightStatus(result.data); + if (result.success && result.data && typeof result.data === 'object' && !Array.isArray(result.data)) { + store.setPreflightStatus(result.data as ReleasePreflightStatus); } else { store.setError(result.error || 'Failed to run pre-flight checks'); } diff --git a/apps/desktop/src/shared/constants/ipc.ts b/apps/desktop/src/shared/constants/ipc.ts index 80e500b0e5..10b5d137d2 100644 --- a/apps/desktop/src/shared/constants/ipc.ts +++ b/apps/desktop/src/shared/constants/ipc.ts @@ -76,6 +76,7 @@ export const IPC_CHANNELS = { TERMINAL_GENERATE_NAME: 'terminal:generateName', TERMINAL_SET_TITLE: 'terminal:setTitle', // Renderer -> Main: user renamed terminal TERMINAL_SET_WORKTREE_CONFIG: 'terminal:setWorktreeConfig', // Renderer -> Main: worktree association changed + TERMINAL_SAVE_BUFFER: 'terminal:saveBuffer', // Renderer -> Main: save terminal buffer content // Terminal session management TERMINAL_GET_SESSIONS: 'terminal:getSessions', @@ -333,11 +334,22 @@ export const IPC_CHANNELS = { GITLAB_MR_ASSIGN: 'gitlab:mr:assign', GITLAB_MR_APPROVE: 'gitlab:mr:approve', GITLAB_MR_CHECK_NEW_COMMITS: 'gitlab:mr:checkNewCommits', + GITLAB_MR_LIST_MORE: 'gitlab:mr:listMore', // Load more MRs (pagination) + GITLAB_MR_DELETE_REVIEW: 'gitlab:mr:deleteReview', // Delete a posted review + GITLAB_MR_CHECK_MERGE_READINESS: 'gitlab:mr:checkMergeReadiness', // Check if MR can be merged + GITLAB_MR_GET_REVIEWS_BATCH: 'gitlab:mr:getReviewsBatch', // Batch load reviews for multiple MRs + GITLAB_MR_FIX: 'gitlab:mr:fix', // Auto-fix issues in MR + GITLAB_MR_GET_LOGS: 'gitlab:mr:getLogs', // Get AI review logs + GITLAB_MR_STATUS_POLL_START: 'gitlab:mr:statusPollStart', // Start polling MR status + GITLAB_MR_STATUS_POLL_STOP: 'gitlab:mr:statusPollStop', // Stop polling MR status + GITLAB_MR_MEMORY_GET: 'gitlab:mr:memory:get', // Get MR review memories + GITLAB_MR_MEMORY_SEARCH: 'gitlab:mr:memory:search', // Search MR review memories // GitLab MR Review events (main -> renderer) GITLAB_MR_REVIEW_PROGRESS: 'gitlab:mr:reviewProgress', GITLAB_MR_REVIEW_COMPLETE: 'gitlab:mr:reviewComplete', GITLAB_MR_REVIEW_ERROR: 'gitlab:mr:reviewError', + GITLAB_MR_STATUS_UPDATE: 'gitlab:mr:statusUpdate', // GitLab Auto-Fix operations GITLAB_AUTOFIX_START: 'gitlab:autofix:start', @@ -469,6 +481,11 @@ export const IPC_CHANNELS = { OLLAMA_PULL_MODEL: 'ollama:pullModel', OLLAMA_PULL_PROGRESS: 'ollama:pullProgress', + // Memory Infrastructure operations (LadybugDB - libSQL) + INFRASTRUCTURE_GET_STATUS: 'infrastructure:getStatus', + INFRASTRUCTURE_LIST_DATABASES: 'infrastructure:listDatabases', + INFRASTRUCTURE_TEST_CONNECTION: 'infrastructure:testConnection', + // Changelog operations CHANGELOG_GET_DONE_TASKS: 'changelog:getDoneTasks', CHANGELOG_LOAD_TASK_SPECS: 'changelog:loadTaskSpecs', diff --git a/apps/desktop/src/shared/i18n/locales/en/gitlab.json b/apps/desktop/src/shared/i18n/locales/en/gitlab.json index 90c13ba65a..dabd802c00 100644 --- a/apps/desktop/src/shared/i18n/locales/en/gitlab.json +++ b/apps/desktop/src/shared/i18n/locales/en/gitlab.json @@ -204,5 +204,104 @@ "performance": "Performance", "logic": "Logic" } + }, + "autoFix": { + "specCreated": "Spec created from issue", + "autoFixFailed": "Auto-fix failed", + "retryAutoFix": "Retry Auto Fix", + "processing": "Processing...", + "autoFix": "Auto Fix" + }, + "batchReview": { + "title": "Analyze & Group Issues", + "description": "This will analyze up to 200 open issues, group similar ones together, and let you review the proposed batches before creating any tasks.", + "startAnalysis": "Start Analysis", + "analyzing": "Analyzing Issues...", + "computingSimilarity": "Computing similarity and validating batches...", + "percentComplete": "{{value}}% complete", + "issuesAnalyzed": "issues analyzed", + "batchesProposed": "batches proposed", + "singleIssues": "single issues", + "selectSingleIssues": "Single Issues (not grouped)", + "andMore": "...and {{count}} more", + "batchesSelected": "{{count}} batch(es) selected ({{issues}} issues)", + "plusSingleIssues": "+ {{count}} single issue(s)", + "creatingBatches": "Creating Batches...", + "settingUpBatches": "Setting up the approved issue batches for processing.", + "batchesCreated": "Batches Created", + "batchesReady": "Your selected issue batches are ready for processing.", + "close": "Close", + "cancel": "Cancel", + "approveAndCreate": "Approve & Create ({{count}} batch(es))", + "creating": "Creating...", + "selectDeselectAll": "Select All", + "deselectAll": "Deselect All", + "issues": "issues", + "similar": "% similar", + "similarPercent": "{{value}}% similar", + "batchNumber": "Batch {{number}}" + }, + "errors": { + "AUTHENTICATION_FAILED": "GitLab authentication failed. Please check your access token.", + "RATE_LIMITED": "GitLab rate limit exceeded. Please wait a moment before trying again.", + "NETWORK_ERROR": "Network error. Please check your connection and try again.", + "PROJECT_NOT_FOUND": "GitLab project not found. Please check your project configuration.", + "INSUFFICIENT_PERMISSIONS": "Insufficient permissions. Please check your GitLab access token scopes.", + "CONFLICT": "There was a conflict with the current state of the resource.", + "UNKNOWN": "An unknown error occurred" + }, + "mrFiltering": { + "reviewed": "Reviewed", + "posted": "Posted", + "changesRequested": "Changes Requested", + "readyToMerge": "Ready to Merge", + "readyForFollowup": "Ready for Follow-up", + "reviewing": "Reviewing", + "notReviewed": "Not Reviewed", + "searchPlaceholder": "Search merge requests...", + "contributors": "Contributors", + "searchContributors": "Search contributors...", + "selectedCount": "{{count}} selected", + "noResultsFound": "No results found", + "clearFilters": "Clear filters", + "allStatuses": "All Statuses", + "clearSearch": "Clear search", + "reset": "Reset", + "sort": { + "label": "Sort", + "newest": "Newest", + "oldest": "Oldest", + "largest": "Largest" + }, + "logs": { + "mrLabel": "MR #{{iid}}", + "agentActivity": "Agent Activity", + "showMore": "Show {{count}} more", + "hideMore": "Hide {{count}} more", + "noLogsYet": "No logs yet", + "waitingForLogs": "Waiting for logs...", + "reviewStarting": "Review is starting", + "noLogsAvailable": "No logs available", + "runReviewGenerateLogs": "Run a review to generate logs", + "entries": "entries", + "less": "Less", + "more": "More", + "followup": "Follow-up", + "live": "Live", + "streaming": "Streaming", + "running": "Running", + "pending": "Pending", + "complete": "Complete", + "failed": "Failed", + "contextGathering": "Context Gathering", + "aiAnalysis": "AI Analysis", + "synthesis": "Synthesis", + "filesRead": "{{count}} file read", + "filesRead_plural": "{{count}} files read", + "searches": "{{count}} search", + "searches_plural": "{{count}} searches", + "other": "{{count}} other", + "operations": "{{count}} operations" + } } } diff --git a/apps/desktop/src/shared/i18n/locales/fr/gitlab.json b/apps/desktop/src/shared/i18n/locales/fr/gitlab.json index 6c21cc8c3b..d75bf75d36 100644 --- a/apps/desktop/src/shared/i18n/locales/fr/gitlab.json +++ b/apps/desktop/src/shared/i18n/locales/fr/gitlab.json @@ -214,5 +214,104 @@ "performance": "Performance", "logic": "Logique" } + }, + "autoFix": { + "specCreated": "Spécification créée à partir de l'issue", + "autoFixFailed": "Échec de la correction automatique", + "retryAutoFix": "Réessayer la correction automatique", + "processing": "Traitement...", + "autoFix": "Correction automatique" + }, + "batchReview": { + "title": "Analyser et grouper les issues", + "description": "Cela analysera jusqu'à 200 issues ouvertes, regroupera celles similaires et vous permettra de vérifier les lots proposés avant de créer des tâches.", + "startAnalysis": "Démarrer l'analyse", + "analyzing": "Analyse des issues...", + "computingSimilarity": "Calcul de la similarité et validation des lots...", + "percentComplete": "{{value}}% complété", + "issuesAnalyzed": "issues analysées", + "batchesProposed": "lots proposés", + "singleIssues": "issues seules", + "selectSingleIssues": "Issues seules (non groupées)", + "andMore": "...et {{count}} autres", + "batchesSelected": "{{count}} lot(s) sélectionné(s) ({{issues}} issues)", + "plusSingleIssues": "+ {{count}} issue(s) seule(s)", + "creatingBatches": "Création des lots...", + "settingUpBatches": "Configuration des lots d'issues approuvés pour le traitement.", + "batchesCreated": "Lots créés", + "batchesReady": "Vos lots d'issues sélectionnés sont prêts pour le traitement.", + "close": "Fermer", + "cancel": "Annuler", + "approveAndCreate": "Approuver et créer ({{count}} lot(s))", + "creating": "Création...", + "selectDeselectAll": "Tout sélectionner", + "deselectAll": "Tout désélectionner", + "issues": "issues", + "similar": "% similaire", + "similarPercent": "{{value}}% similaire", + "batchNumber": "Lot {{number}}" + }, + "errors": { + "AUTHENTICATION_FAILED": "Échec de l'authentification GitLab. Veuillez vérifier votre token d'accès.", + "RATE_LIMITED": "Limite de taux GitLab dépassée. Veuillez attendre un moment avant de réessayer.", + "NETWORK_ERROR": "Erreur réseau. Veuillez vérifier votre connexion et réessayer.", + "PROJECT_NOT_FOUND": "Projet GitLab introuvable. Veuillez vérifier la configuration de votre projet.", + "INSUFFICIENT_PERMISSIONS": "Autorisations insuffisantes. Veuillez vérifier les scopes de votre token d'accès GitLab.", + "CONFLICT": "Il y a eu un conflit avec l'état actuel de la ressource.", + "UNKNOWN": "Une erreur inconnue s'est produite" + }, + "mrFiltering": { + "reviewed": "Analysée", + "posted": "Publié", + "changesRequested": "Modifications demandées", + "readyToMerge": "Prête à fusionner", + "readyForFollowup": "Prête pour suivi", + "reviewing": "Analyse en cours", + "notReviewed": "Non analysée", + "searchPlaceholder": "Rechercher des merge requests...", + "contributors": "Contributeurs", + "searchContributors": "Rechercher des contributeurs...", + "selectedCount": "{{count}} sélectionné(s)", + "noResultsFound": "Aucun résultat trouvé", + "clearFilters": "Effacer les filtres", + "allStatuses": "Tous les statuts", + "clearSearch": "Effacer la recherche", + "reset": "Réinitialiser", + "sort": { + "label": "Trier", + "newest": "Plus récent", + "oldest": "Plus ancien", + "largest": "Plus grand" + }, + "logs": { + "mrLabel": "MR n°{{iid}}", + "agentActivity": "Activité de l'agent", + "showMore": "Afficher {{count}} autres", + "hideMore": "Masquer {{count}} autres", + "noLogsYet": "Pas encore de logs", + "waitingForLogs": "En attente des logs...", + "reviewStarting": "La revue commence", + "noLogsAvailable": "Aucun log disponible", + "runReviewGenerateLogs": "Lancez une revue pour générer des logs", + "entries": "entrées", + "less": "Moins", + "more": "Plus", + "followup": "Suivi", + "live": "En direct", + "streaming": "Diffusion", + "running": "En cours", + "pending": "En attente", + "complete": "Terminé", + "failed": "Échoué", + "contextGathering": "Collecte du contexte", + "aiAnalysis": "Analyse IA", + "synthesis": "Synthèse", + "filesRead": "{{count}} fichier lu", + "filesRead_plural": "{{count}} fichiers lus", + "searches": "{{count}} recherche", + "searches_plural": "{{count}} recherches", + "other": "{{count}} autre", + "operations": "{{count}} opérations" + } } } diff --git a/apps/desktop/src/shared/integrations/filters/__tests__/filter-utils.test.ts b/apps/desktop/src/shared/integrations/filters/__tests__/filter-utils.test.ts new file mode 100644 index 0000000000..e59351844f --- /dev/null +++ b/apps/desktop/src/shared/integrations/filters/__tests__/filter-utils.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect } from 'vitest'; +import { applyFilter, getFilterPredicate, isValidFilterState } from '../filter-utils'; +import type { FilterState } from '../../types/base-types'; + +describe('filter-utils', () => { + interface TestItem { + id: number; + state: string; // Use string to accommodate different state formats + } + + it('should filter by opened state', () => { + const items: TestItem[] = [ + { id: 1, state: 'opened' }, + { id: 2, state: 'closed' }, + { id: 3, state: 'opened' } + ]; + const result = applyFilter(items, 'opened'); + expect(result).toHaveLength(2); + expect(result.every((i: TestItem) => i.state === 'opened')).toBe(true); + }); + + it('should normalize open/opened states (GitHub vs GitLab)', () => { + const items: TestItem[] = [ + { id: 1, state: 'open' }, // GitHub format + { id: 2, state: 'opened' }, // GitLab format + { id: 3, state: 'closed' } + ]; + // Filter with 'opened' should match both 'open' and 'opened' + const result = applyFilter(items, 'opened'); + expect(result).toHaveLength(2); + expect(result.map((i: TestItem) => i.id)).toEqual([1, 2]); + }); + + it('should handle GitHub native open filter (regression)', () => { + const items: TestItem[] = [ + { id: 1, state: 'open' }, // GitHub format + { id: 2, state: 'opened' }, // GitLab format + { id: 3, state: 'closed' } + ]; + // Filter with 'open' (GitHub native) should also match both + const result = applyFilter(items, 'open'); + expect(result).toHaveLength(2); + expect(result.map((i: TestItem) => i.id)).toEqual([1, 2]); + }); + + it('should return all items for "all" filter', () => { + const items: TestItem[] = [ + { id: 1, state: 'opened' }, + { id: 2, state: 'closed' } + ]; + const result = applyFilter(items, 'all'); + expect(result).toHaveLength(2); + }); + + it('should create filter predicate', () => { + const predicate = getFilterPredicate('opened'); + expect(predicate({ state: 'opened' } as TestItem)).toBe(true); + expect(predicate({ state: 'open' } as TestItem)).toBe(true); // Normalized + expect(predicate({ state: 'closed' } as TestItem)).toBe(false); + }); + + it('should filter by closed state', () => { + const items: TestItem[] = [ + { id: 1, state: 'opened' }, + { id: 2, state: 'closed' }, + { id: 3, state: 'opened' } + ]; + const result = applyFilter(items, 'closed'); + expect(result).toHaveLength(1); + expect(result[0].state).toBe('closed'); + }); + + it('should filter by merged state', () => { + const items: TestItem[] = [ + { id: 1, state: 'opened' }, + { id: 2, state: 'merged' }, + { id: 3, state: 'closed' } + ]; + const result = applyFilter(items, 'merged'); + expect(result).toHaveLength(1); + expect(result[0].state).toBe('merged'); + }); + + it('should validate filter states', () => { + // Both 'open' and 'opened' should be valid + expect(isValidFilterState('open')).toBe(true); + expect(isValidFilterState('opened')).toBe(true); + expect(isValidFilterState('closed')).toBe(true); + expect(isValidFilterState('merged')).toBe(true); + expect(isValidFilterState('all')).toBe(true); + // Invalid states + expect(isValidFilterState('invalid')).toBe(false); + expect(isValidFilterState('')).toBe(false); + }); +}); diff --git a/apps/desktop/src/shared/integrations/filters/filter-utils.ts b/apps/desktop/src/shared/integrations/filters/filter-utils.ts new file mode 100644 index 0000000000..da76a34527 --- /dev/null +++ b/apps/desktop/src/shared/integrations/filters/filter-utils.ts @@ -0,0 +1,46 @@ +/** + * Shared filter utilities for integrations + * + * Handles platform differences: + * - GitHub uses 'open', GitLab uses 'opened' for active state + * - Both use 'closed' and 'all' + * - GitLab MRs additionally have 'merged' state + */ +import type { FilterState } from '../types/base-types'; + +export interface Filterable { + state: string; +} + +/** + * Normalize 'open' and 'opened' to a common key for comparison + * GitHub uses 'open', GitLab uses 'opened' - treat them as equivalent + */ +function normalizeState(state: string): string { + if (state === 'open' || state === 'opened') { + return 'open'; // Normalize to 'open' for comparison + } + return state; +} + +export function applyFilter( + items: T[], + filterState: FilterState +): T[] { + if (filterState === 'all') return items; + + // Normalize both for comparison (handles 'open' vs 'opened') + const normalizedFilter = normalizeState(filterState); + return items.filter(item => normalizeState(item.state) === normalizedFilter); +} + +export function getFilterPredicate(filterState: FilterState) { + return (item: Filterable): boolean => { + if (filterState === 'all') return true; + return normalizeState(item.state) === normalizeState(filterState); + }; +} + +export function isValidFilterState(value: string): value is FilterState { + return ['open', 'opened', 'closed', 'merged', 'all'].includes(value); +} diff --git a/apps/desktop/src/shared/integrations/pagination/__tests__/pagination-utils.test.ts b/apps/desktop/src/shared/integrations/pagination/__tests__/pagination-utils.test.ts new file mode 100644 index 0000000000..6a35305193 --- /dev/null +++ b/apps/desktop/src/shared/integrations/pagination/__tests__/pagination-utils.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from 'vitest'; +import { calculateHasMore, appendWithoutDuplicates, getNextPage, resetPagination } from '../pagination-utils'; + +describe('pagination-utils', () => { + it('should calculate hasMore correctly', () => { + expect(calculateHasMore(100, 50)).toBe(true); + expect(calculateHasMore(50, 50)).toBe(false); + expect(calculateHasMore(10, 50)).toBe(false); + }); + + it('should append items without duplicates', () => { + const existing = [{ id: 1 }, { id: 2 }]; + const newItems = [{ id: 2 }, { id: 3 }]; + const result = appendWithoutDuplicates(existing, newItems, 'id'); + expect(result).toHaveLength(3); + expect(result.map((i: { id: number }) => i.id)).toEqual([1, 2, 3]); + }); + + it('should handle empty arrays', () => { + const result = appendWithoutDuplicates([], [{ id: 1 }], 'id'); + expect(result).toHaveLength(1); + }); + + it('should handle all duplicates', () => { + const existing = [{ id: 1 }, { id: 2 }]; + const newItems = [{ id: 1 }, { id: 2 }]; + const result = appendWithoutDuplicates(existing, newItems, 'id'); + expect(result).toHaveLength(2); + }); + + it('should get next page', () => { + expect(getNextPage(1)).toBe(2); + expect(getNextPage(5)).toBe(6); + expect(getNextPage(0)).toBe(1); + }); + + it('should reset pagination', () => { + const result = resetPagination(); + expect(result.currentPage).toBe(1); + expect(result.hasMore).toBe(true); + }); +}); diff --git a/apps/desktop/src/shared/integrations/pagination/pagination-utils.ts b/apps/desktop/src/shared/integrations/pagination/pagination-utils.ts new file mode 100644 index 0000000000..789e5faed7 --- /dev/null +++ b/apps/desktop/src/shared/integrations/pagination/pagination-utils.ts @@ -0,0 +1,35 @@ +/** + * Shared pagination utilities for integrations + */ + +/** + * Determine if there are more items to load based on total count and page size. + * Returns true if totalCount exceeds pageSize, indicating additional pages exist. + * @param totalCount - Total number of items available + * @param pageSize - Number of items per page + * @returns true if there are more pages to load + */ +export function calculateHasMore(totalCount: number, pageSize: number): boolean { + return totalCount > pageSize; +} + +export function appendWithoutDuplicates( + existing: T[], + newItems: T[], + key: keyof T +): T[] { + const existingKeys = new Set(existing.map(item => String(item[key]))); + const uniqueNewItems = newItems.filter(item => !existingKeys.has(String(item[key]))); + return [...existing, ...uniqueNewItems]; +} + +export function getNextPage(currentPage: number): number { + return currentPage + 1; +} + +export function resetPagination(): { currentPage: number; hasMore: boolean } { + return { + currentPage: 1, + hasMore: true + }; +} diff --git a/apps/desktop/src/shared/integrations/types/__tests__/base-types.test.ts b/apps/desktop/src/shared/integrations/types/__tests__/base-types.test.ts new file mode 100644 index 0000000000..b4094fdc78 --- /dev/null +++ b/apps/desktop/src/shared/integrations/types/__tests__/base-types.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from 'vitest'; +import type { IntegrationError, SyncStatus, InvestigationStatus } from '../base-types'; + +describe('base-types', () => { + it('should create IntegrationError', () => { + const error: IntegrationError = { + code: 'RATE_LIMITED', + message: 'Too many requests', + recoverable: true + }; + expect(error.code).toBe('RATE_LIMITED'); + expect(error.recoverable).toBe(true); + }); + + it('should create SyncStatus', () => { + const status: SyncStatus = { + connected: true, + repoFullName: 'group/project' + }; + expect(status.connected).toBe(true); + }); + + it('should create InvestigationStatus', () => { + const status: InvestigationStatus = { + phase: 'fetching', + progress: 50, + message: 'Analyzing...' + }; + expect(status.phase).toBe('fetching'); + }); +}); diff --git a/apps/desktop/src/shared/integrations/types/base-types.ts b/apps/desktop/src/shared/integrations/types/base-types.ts new file mode 100644 index 0000000000..1e7b109b0c --- /dev/null +++ b/apps/desktop/src/shared/integrations/types/base-types.ts @@ -0,0 +1,48 @@ +/** + * Shared integration types for GitHub and GitLab + */ + +export interface IntegrationError { + code: string; + message: string; + details?: unknown; + recoverable: boolean; +} + +export interface SyncStatus { + connected: boolean; + repoFullName: string | null; +} + +export interface InvestigationStatus { + phase: 'idle' | 'fetching' | 'analyzing' | 'complete' | 'error'; + progress: number; + message: string; + issueNumber?: number; + mrIid?: number; +} + +export interface InvestigationResult { + issueNumber?: number; + mrIid?: number; + summary: string; + findings: string[]; + relatedFiles: string[]; + suggestedActions: string[]; +} + +export interface PaginationState { + currentPage: number; + hasMore: boolean; + isLoadingMore: boolean; +} + +/** + * Platform-specific filter states + * GitHub uses 'open', GitLab uses 'opened' + * Both use 'closed' and 'all' + * GitLab additionally has 'merged' for MRs + */ +export type GitHubFilterState = 'open' | 'closed' | 'all'; +export type GitLabFilterState = 'opened' | 'closed' | 'merged' | 'all'; +export type FilterState = GitHubFilterState | GitLabFilterState; diff --git a/apps/desktop/src/shared/types/integrations.ts b/apps/desktop/src/shared/types/integrations.ts index 741e388f33..3a07986ea0 100644 --- a/apps/desktop/src/shared/types/integrations.ts +++ b/apps/desktop/src/shared/types/integrations.ts @@ -324,6 +324,7 @@ export interface GitLabMRReviewProgress { export interface GitLabNewCommitsCheck { hasNewCommits: boolean; + hasCommitsAfterPosting?: boolean; // True if commits were added AFTER the review was posted currentSha?: string; reviewedSha?: string; newCommitCount?: number; @@ -375,6 +376,27 @@ export interface GitLabAutoFixProgress { message: string; } +export interface GitLabAnalyzePreviewProgress { + message: string; + progress: number; +} + +export interface GitLabProposedBatch { + primaryIssue: number; + issues: Array<{ + iid: number; + title: string; + labels: string[]; + similarityToPrimary: number; + }>; + issueCount: number; + commonThemes: string[]; + validated: boolean; + confidence: number; + reasoning: string; + theme: string; +} + export interface GitLabAnalyzePreviewResult { success: boolean; totalIssues: number; @@ -478,3 +500,19 @@ export interface RoadmapProviderConfig { * Canny-specific status values */ export type CannyStatus = 'open' | 'under review' | 'planned' | 'in progress' | 'complete' | 'closed'; + +/** + * MR status update event - sent from main process to renderer + */ +export interface GitLabMRStatusUpdate { + /** Project ID */ + projectId: string; + /** Merge request IID */ + mrIid: number; + /** Current state of the MR */ + state: string; + /** Merge status */ + mergeStatus: string; + /** Last update timestamp */ + updatedAt: string; +} diff --git a/apps/desktop/src/shared/types/ipc.ts b/apps/desktop/src/shared/types/ipc.ts index eb99c71553..fec154298e 100644 --- a/apps/desktop/src/shared/types/ipc.ts +++ b/apps/desktop/src/shared/types/ipc.ts @@ -170,6 +170,12 @@ export interface GitBranchDetail { // ============================================ // Electron API exposed via contextBridge +// Import from preload to avoid duplication +import type { ElectronAPI as PreloadElectronAPI } from '@preload/api'; + +// Re-export ElectronAPI type from preload +export type ElectronAPI = PreloadElectronAPI; + // Tab state interface (persisted in main process) export interface TabState { openProjectIds: string[]; @@ -177,7 +183,14 @@ export interface TabState { tabOrder: string[]; } -export interface ElectronAPI { +// Legacy: Keep the old interface for reference, but use the imported type above +// This will be removed once all references are updated +/** + * @deprecated This interface is kept for migration reference only. + * Use the `ElectronAPI` type alias instead. + * TODO: Remove once all references are updated. + */ +export interface ElectronAPILegacy { // Project operations addProject: (projectPath: string) => Promise>; removeProject: (projectId: string) => Promise;