Compare commits

..

1 Commits

Author SHA1 Message Date
L'électron rare ac04f97067 feat: add Mascarade as LLM provider — multi-agent orchestration engine
- Add 'mascarade' to SupportedProvider enum (types.ts)
- Add Mascarade case in createProviderInstance using OpenAI-compatible SDK (factory.ts)
- Add 'mascarade' to BuiltinProvider type (provider-account.ts)
- Add mascarade- prefix to MODEL_PROVIDER_MAP (config/types.ts)
- Add 5 mascarade models: router, writer, coder, analyst, planner (models.ts)
- Add mascarade provider presets for all agent profiles (models.ts)
- Add mascarade to PROVIDER_INFOS with UI metadata (providers.ts)

Mascarade is a self-hosted LLM orchestration engine (50K LOC Python)
that routes requests across Claude, OpenAI, Mistral, Google and Ollama
with intelligent routing strategies (best/cheapest/fastest/domain).
Default endpoint: http://localhost:8100/v1 (OpenAI-compatible)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 13:24:21 +01:00
62 changed files with 124 additions and 5005 deletions
+1 -2
View File
@@ -45,8 +45,7 @@
"noProcessEnv": "off",
"useNodejsImportProtocol": "off",
"useImportType": "off",
"useTemplate": "off",
"noNonNullAssertion": "warn"
"useTemplate": "off"
},
"suspicious": {
"recommended": true,
+4 -7
View File
@@ -7,20 +7,17 @@ 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<string, string> = {
userData: path.join(os.homedir(), '.aperant-test-data'),
home: os.homedir(),
temp: os.tmpdir()
userData: '/tmp/test-app-data',
home: '/tmp/test-home',
temp: '/tmp'
};
return paths[name] || path.join(os.homedir(), '.aperant-test');
return paths[name] || '/tmp';
}),
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()
};
@@ -209,6 +209,7 @@ 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);
});
@@ -223,6 +224,7 @@ 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);
});
@@ -89,10 +89,6 @@ 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(() => {
+1
View File
@@ -147,6 +147,7 @@ export const MODEL_PROVIDER_MAP: Record<string, SupportedProvider> = {
'llama-': 'groq',
'grok-': 'xai',
'glm-': 'zai',
'mascarade-': 'mascarade',
} as const;
// ============================================
@@ -156,6 +156,24 @@ function createProviderInstance(config: ProviderConfig) {
});
}
case SupportedProvider.Mascarade: {
// Mascarade LLM orchestration engine — OpenAI-compatible API
// Default: http://localhost:8100/v1 (Tower) or configurable
let mascaradeBaseURL = baseURL ?? 'http://localhost:8100/v1';
if (!mascaradeBaseURL.endsWith('/v1')) {
mascaradeBaseURL = mascaradeBaseURL.replace(/\/+$/, '') + '/v1';
}
return createOpenAICompatible({
name: 'mascarade',
apiKey: apiKey ?? 'mascarade-local',
baseURL: mascaradeBaseURL,
headers: {
...headers,
'Authorization': `Bearer ${apiKey ?? 'mascarade-local'}`,
},
});
}
default: {
const _exhaustive: never = provider;
throw new Error(`Unsupported provider: ${_exhaustive}`);
@@ -21,6 +21,7 @@ export const SupportedProvider = {
OpenRouter: 'openrouter',
ZAI: 'zai',
Ollama: 'ollama',
Mascarade: 'mascarade',
} as const;
export type SupportedProvider = (typeof SupportedProvider)[keyof typeof SupportedProvider];
@@ -499,6 +499,7 @@ 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}`,
-10
View File
@@ -385,16 +385,6 @@ 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;
});
}
@@ -304,10 +304,9 @@ function sendProgress(
function sendError(
mainWindow: BrowserWindow,
projectId: string,
issueIid: number,
error: string
): void {
mainWindow.webContents.send(IPC_CHANNELS.GITLAB_AUTOFIX_ERROR, projectId, issueIid, error);
mainWindow.webContents.send(IPC_CHANNELS.GITLAB_AUTOFIX_ERROR, projectId, error);
}
/**
@@ -499,7 +498,7 @@ export function registerAutoFixHandlers(
});
} catch (error) {
debugLog('Auto-fix failed', { issueIid, error: error instanceof Error ? error.message : error });
sendError(mainWindow, projectId, issueIid, error instanceof Error ? error.message : 'Failed to start auto-fix');
sendError(mainWindow, projectId, error instanceof Error ? error.message : 'Failed to start auto-fix');
}
}
);
@@ -10,14 +10,15 @@
* 6. Approve MR
*/
import { ipcMain, BrowserWindow } from 'electron';
import { ipcMain } from 'electron';
import type { 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, IPCResult, GitLabMergeRequest } from '../../../shared/types';
import type { Project, AppSettings } from '../../../shared/types';
import type {
MRReviewResult,
MRReviewProgress,
@@ -25,7 +26,6 @@ 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,27 +47,6 @@ 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<string, NodeJS.Timeout>();
const pollingInProgress = new Set<string>();
/**
* 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
*/
@@ -811,7 +790,6 @@ 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 };
@@ -835,40 +813,27 @@ export function registerMRReviewHandlers(
if (reviewedCommitSha === currentHeadSha) {
return {
hasNewCommits: false,
hasCommitsAfterPosting: false,
currentSha: currentHeadSha,
reviewedSha: reviewedCommitSha,
};
}
// Get commits to count new ones and check for commits after review posting
// Get commits to count new ones
const commits = await gitlabFetch(
config.token,
config.instanceUrl,
`/projects/${encodedProject}/merge_requests/${mrIid}/commits`
) as Array<{ id: string; created_at: string }>;
) as Array<{ id: 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,
@@ -1008,431 +973,4 @@ 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<IPCResult<{ deleted: boolean }>> => {
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<IPCResult<{
canMerge: boolean;
hasConflicts: boolean;
needsDiscussion: boolean;
pipelineStatus?: string;
}>> => {
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<IPCResult<string[]>> => {
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<IPCResult<{ polling: boolean }>> => {
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<IPCResult<{ stopped: boolean }>> => {
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<IPCResult<unknown[]>> => {
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<unknown[]>;
}
);
/**
* Search MR review memories
*/
ipcMain.handle(
IPC_CHANNELS.GITLAB_MR_MEMORY_SEARCH,
async (_event, projectId: string, query: string): Promise<IPCResult<unknown[]>> => {
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<unknown[]>;
}
);
/**
* Auto-fix issues in MR
*/
ipcMain.handle(
IPC_CHANNELS.GITLAB_MR_FIX,
async (_event, projectId: string, mrIid: number, findings: string[]): Promise<IPCResult<{ fixed: number }>> => {
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<IPCResult<Record<number, MRReviewResult | null>>> => {
debugLog('getReviewsBatch handler called', { projectId, mrIids });
const result = await withProjectOrNull(projectId, async (project) => {
const results: Record<number, MRReviewResult | null> = {};
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<IPCResult<{ mrs: GitLabMergeRequest[]; hasMore: boolean }>> => {
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 };
@@ -12,12 +12,8 @@ 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
@@ -551,154 +547,6 @@ 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<IPCResult<InfrastructureStatus>>} Status with memory database info
*/
ipcMain.handle(
IPC_CHANNELS.INFRASTRUCTURE_GET_STATUS,
async (_, dbPath?: string): Promise<IPCResult<InfrastructureStatus>> => {
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<IPCResult<string[]>>} Array of database names
*/
ipcMain.handle(
IPC_CHANNELS.INFRASTRUCTURE_LIST_DATABASES,
async (_, dbPath?: string): Promise<IPCResult<string[]>> => {
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<IPCResult<MemoryValidationResult>>} Connection test result
*/
ipcMain.handle(
IPC_CHANNELS.INFRASTRUCTURE_TEST_CONNECTION,
async (_, dbPath?: string, database?: string): Promise<IPCResult<MemoryValidationResult>> => {
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
// ============================================
@@ -266,9 +266,6 @@ export function registerProjectHandlers(
ipcMain.handle(
IPC_CHANNELS.PROJECT_REMOVE,
async (_, projectId: string): Promise<IPCResult> => {
// 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 };
}
@@ -12,7 +12,6 @@ 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';
/**
@@ -116,22 +115,6 @@ export function registerTerminalHandlers(
}
);
// Save terminal buffer (serialized xterm.js content)
ipcMain.handle(
IPC_CHANNELS.TERMINAL_SAVE_BUFFER,
async (_, terminalId: string, serializedBuffer: string): Promise<IPCResult> => {
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,
@@ -14,23 +14,8 @@ import type {
TerminalRecoveryInfo,
} from '../../shared/types/terminal-session';
// 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');
const SESSIONS_FILE = path.join(app.getPath('userData'), 'terminal-sessions.json');
const BUFFERS_DIR = path.join(app.getPath('userData'), 'terminal-buffers');
// Session age limit: 7 days
const MAX_SESSION_AGE_MS = 7 * 24 * 60 * 60 * 1000;
@@ -322,31 +307,19 @@ class SessionPersistence {
// Singleton instance
export const sessionPersistence = new SessionPersistence();
// 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();
});
// Hook into app lifecycle
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
}
app.on('will-quit', () => {
sessionPersistence.saveNow();
});
// Cleanup orphaned buffers on startup (after initial load)
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
}
app.whenReady().then(() => {
setTimeout(() => {
sessionPersistence.cleanupOrphanedBuffers();
}, 5000); // Wait 5 seconds after app ready
});
@@ -178,28 +178,10 @@ export interface GitHubAPI {
/** AI-powered version suggestion based on commits since last release */
suggestReleaseVersion: (projectId: string) => Promise<IPCResult<VersionSuggestion>>;
// Release operations (changelog-based)
getReleaseableVersions: (projectId: string) => Promise<IPCResult<unknown>>;
runReleasePreflightCheck: (projectId: string, version: string) => Promise<IPCResult<unknown>>;
createRelease: (options: {
projectId: string;
version: string;
body: string;
draft?: boolean;
prerelease?: boolean;
}) => Promise<IPCResult<unknown>>;
// OAuth operations (gh CLI)
checkGitHubCli: () => Promise<IPCResult<{ installed: boolean; version?: string }>>;
checkGitHubAuth: () => Promise<IPCResult<{ authenticated: boolean; username?: string }>>;
startGitHubAuth: () => Promise<IPCResult<{
success: boolean;
message?: string;
deviceCode?: string;
authUrl?: string;
browserOpened?: boolean;
fallbackUrl?: string;
}>>;
startGitHubAuth: () => Promise<IPCResult<{ success: boolean; message?: string }>>;
getGitHubToken: () => Promise<IPCResult<{ token: string }>>;
getGitHubUser: () => Promise<IPCResult<{ username: string; name?: string }>>;
listGitHubUserRepos: () => Promise<IPCResult<{ repos: Array<{ fullName: string; description: string | null; isPrivate: boolean }> }>>;
@@ -589,22 +571,6 @@ export const createGitHubAPI = (): GitHubAPI => ({
suggestReleaseVersion: (projectId: string): Promise<IPCResult<VersionSuggestion>> =>
invokeIpc(IPC_CHANNELS.RELEASE_SUGGEST_VERSION, projectId),
// Release operations (changelog-based)
getReleaseableVersions: (projectId: string): Promise<IPCResult<unknown>> =>
invokeIpc(IPC_CHANNELS.RELEASE_GET_VERSIONS, projectId),
runReleasePreflightCheck: (projectId: string, version: string): Promise<IPCResult<unknown>> =>
invokeIpc(IPC_CHANNELS.RELEASE_PREFLIGHT, projectId, version),
createRelease: (options: {
projectId: string;
version: string;
body: string;
draft?: boolean;
prerelease?: boolean;
}): Promise<IPCResult<unknown>> =>
invokeIpc(IPC_CHANNELS.RELEASE_CREATE, options),
// OAuth operations (gh CLI)
checkGitHubCli: (): Promise<IPCResult<{ installed: boolean; version?: string }>> =>
invokeIpc(IPC_CHANNELS.GITHUB_CHECK_CLI),
@@ -612,14 +578,7 @@ export const createGitHubAPI = (): GitHubAPI => ({
checkGitHubAuth: (): Promise<IPCResult<{ authenticated: boolean; username?: string }>> =>
invokeIpc(IPC_CHANNELS.GITHUB_CHECK_AUTH),
startGitHubAuth: (): Promise<IPCResult<{
success: boolean;
message?: string;
deviceCode?: string;
authUrl?: string;
browserOpened?: boolean;
fallbackUrl?: string;
}>> =>
startGitHubAuth: (): Promise<IPCResult<{ success: boolean; message?: string }>> =>
invokeIpc(IPC_CHANNELS.GITHUB_START_AUTH),
getGitHubToken: (): Promise<IPCResult<{ token: string }>> =>
@@ -18,7 +18,6 @@ import type {
GitLabAnalyzePreviewResult,
GitLabTriageConfig,
GitLabTriageResult,
GitLabMRStatusUpdate,
GitLabGroup,
IPCResult
} from '../../../shared/types';
@@ -80,30 +79,6 @@ export interface GitLabAPI {
cancelGitLabMRReview: (projectId: string, mrIid: number) => Promise<boolean>;
checkGitLabMRNewCommits: (projectId: string, mrIid: number) => Promise<GitLabNewCommitsCheck>;
// NEW: Additional MR operations for feature parity
listMoreGitLabMRs: (
projectId: string,
state?: 'opened' | 'closed' | 'merged' | 'all',
page?: number
) => Promise<IPCResult<{ mrs: GitLabMergeRequest[]; hasMore: boolean }>>;
getGitLabMRReviewsBatch: (projectId: string, mrIids: number[]) => Promise<IPCResult<Record<number, GitLabMRReviewResult | null>>>;
deleteGitLabMRReview: (projectId: string, mrIid: number, noteId: number) => Promise<IPCResult<{ deleted: boolean }>>;
fixGitLabMR: (projectId: string, mrIid: number, findings: string[]) => Promise<IPCResult<{ fixed: number }>>;
startGitLabMRStatusPoll: (projectId: string, mrIid: number, intervalMs?: number) => Promise<IPCResult<{ polling: boolean }>>;
stopGitLabMRStatusPoll: (projectId: string, mrIid: number) => Promise<IPCResult<{ stopped: boolean }>>;
getGitLabMRLogs: (projectId: string, mrIid: number) => Promise<IPCResult<string[]>>;
getGitLabMRMemory: (projectId: string, mrIid: number) => Promise<IPCResult<unknown[]>>;
searchGitLabMRMemory: (projectId: string, query: string) => Promise<IPCResult<unknown[]>>;
checkGitLabMRMergeReadiness: (
projectId: string,
mrIid: number
) => Promise<IPCResult<{
canMerge: boolean;
hasConflicts: boolean;
needsDiscussion: boolean;
pipelineStatus?: string;
}>>;
// MR Review Event Listeners
onGitLabMRReviewProgress: (
callback: (projectId: string, progress: GitLabMRReviewProgress) => void
@@ -114,9 +89,6 @@ 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<GitLabAutoFixConfig | null>;
@@ -137,7 +109,7 @@ export interface GitLabAPI {
callback: (projectId: string, result: GitLabAutoFixQueueItem) => void
) => IpcListenerCleanup;
onGitLabAutoFixError: (
callback: (projectId: string, issueIid: number, error: string) => void
callback: (projectId: string, error: string) => void
) => IpcListenerCleanup;
onGitLabAutoFixAnalyzePreviewProgress: (
callback: (projectId: string, progress: { phase: string; progress: number; message: string }) => void
@@ -306,56 +278,6 @@ export const createGitLabAPI = (): GitLabAPI => ({
checkGitLabMRNewCommits: (projectId: string, mrIid: number): Promise<GitLabNewCommitsCheck> =>
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<IPCResult<{ mrs: GitLabMergeRequest[]; hasMore: boolean }>> =>
invokeIpc(IPC_CHANNELS.GITLAB_MR_LIST_MORE, projectId, state, page),
getGitLabMRReviewsBatch: (
projectId: string,
mrIids: number[]
): Promise<IPCResult<Record<number, GitLabMRReviewResult | null>>> =>
invokeIpc(IPC_CHANNELS.GITLAB_MR_GET_REVIEWS_BATCH, projectId, mrIids),
deleteGitLabMRReview: (projectId: string, mrIid: number, noteId: number): Promise<IPCResult<{ deleted: boolean }>> =>
invokeIpc(IPC_CHANNELS.GITLAB_MR_DELETE_REVIEW, projectId, mrIid, noteId),
fixGitLabMR: (projectId: string, mrIid: number, findings: string[]): Promise<IPCResult<{ fixed: number }>> =>
invokeIpc(IPC_CHANNELS.GITLAB_MR_FIX, projectId, mrIid, findings),
startGitLabMRStatusPoll: (
projectId: string,
mrIid: number,
intervalMs?: number
): Promise<IPCResult<{ polling: boolean }>> =>
invokeIpc(IPC_CHANNELS.GITLAB_MR_STATUS_POLL_START, projectId, mrIid, intervalMs),
stopGitLabMRStatusPoll: (projectId: string, mrIid: number): Promise<IPCResult<{ stopped: boolean }>> =>
invokeIpc(IPC_CHANNELS.GITLAB_MR_STATUS_POLL_STOP, projectId, mrIid),
getGitLabMRLogs: (projectId: string, mrIid: number): Promise<IPCResult<string[]>> =>
invokeIpc(IPC_CHANNELS.GITLAB_MR_GET_LOGS, projectId, mrIid),
getGitLabMRMemory: (projectId: string, mrIid: number): Promise<IPCResult<unknown[]>> =>
invokeIpc(IPC_CHANNELS.GITLAB_MR_MEMORY_GET, projectId, mrIid),
searchGitLabMRMemory: (projectId: string, query: string): Promise<IPCResult<unknown[]>> =>
invokeIpc(IPC_CHANNELS.GITLAB_MR_MEMORY_SEARCH, projectId, query),
checkGitLabMRMergeReadiness: (
projectId: string,
mrIid: number
): Promise<IPCResult<{
canMerge: boolean;
hasConflicts: boolean;
needsDiscussion: boolean;
pipelineStatus?: string;
}>> =>
invokeIpc(IPC_CHANNELS.GITLAB_MR_CHECK_MERGE_READINESS, projectId, mrIid),
// MR Review Event Listeners
onGitLabMRReviewProgress: (
callback: (projectId: string, progress: GitLabMRReviewProgress) => void
@@ -372,11 +294,6 @@ 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<GitLabAutoFixConfig | null> =>
invokeIpc(IPC_CHANNELS.GITLAB_AUTOFIX_GET_CONFIG, projectId),
@@ -417,7 +334,7 @@ export const createGitLabAPI = (): GitLabAPI => ({
createIpcListener(IPC_CHANNELS.GITLAB_AUTOFIX_COMPLETE, callback),
onGitLabAutoFixError: (
callback: (projectId: string, issueIid: number, error: string) => void
callback: (projectId: string, error: string) => void
): IpcListenerCleanup =>
createIpcListener(IPC_CHANNELS.GITLAB_AUTOFIX_ERROR, callback),
@@ -11,6 +11,5 @@ 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';
+2 -19
View File
@@ -9,9 +9,7 @@ import type {
ProjectEnvConfig,
GitStatus,
KanbanPreferences,
GitBranchDetail,
InfrastructureStatus,
MemoryValidationResult
GitBranchDetail
} from '../../shared/types';
// Tab state interface (persisted in main process)
@@ -48,11 +46,6 @@ export interface ProjectAPI {
searchMemories: (projectId: string, query: string) => Promise<IPCResult<unknown>>;
getRecentMemories: (projectId: string, limit?: number) => Promise<IPCResult<unknown>>;
// Memory Infrastructure operations (LadybugDB - no Docker required)
getMemoryInfrastructureStatus: (dbPath?: string) => Promise<IPCResult<InfrastructureStatus>>;
listMemoryDatabases: (dbPath?: string) => Promise<IPCResult<string[]>>;
testMemoryConnection: (dbPath?: string, database?: string) => Promise<IPCResult<MemoryValidationResult>>;
// Memory Management
verifyMemory: (memoryId: string) => Promise<IPCResult<void>>;
pinMemory: (memoryId: string, pinned: boolean) => Promise<IPCResult<void>>;
@@ -291,15 +284,5 @@ 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),
// 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)
ipcRenderer.invoke(IPC_CHANNELS.OLLAMA_PULL_MODEL, modelName, baseUrl)
});
@@ -17,7 +17,6 @@ export interface SettingsAPI {
python: ToolDetectionResult;
git: ToolDetectionResult;
gh: ToolDetectionResult;
glab: ToolDetectionResult;
claude: ToolDetectionResult;
}>>;
@@ -65,7 +64,6 @@ export const createSettingsAPI = (): SettingsAPI => ({
python: ToolDetectionResult;
git: ToolDetectionResult;
gh: ToolDetectionResult;
glab: ToolDetectionResult;
claude: ToolDetectionResult;
}>> =>
ipcRenderer.invoke(IPC_CHANNELS.SETTINGS_GET_CLI_TOOLS_INFO),
@@ -72,9 +72,6 @@ export interface TerminalAPI {
removeTerminalWorktree: (projectPath: string, name: string, deleteBranch?: boolean) => Promise<IPCResult>;
listOtherWorktrees: (projectPath: string) => Promise<IPCResult<OtherWorktreeInfo[]>>;
// Terminal Buffer Persistence
saveTerminalBuffer: (terminalId: string, serializedBuffer: string) => Promise<IPCResult>;
// Terminal Event Listeners
onTerminalOutput: (callback: (id: string, data: string) => void) => () => void;
onTerminalExit: (callback: (id: string, exitCode: number) => void) => () => void;
@@ -213,10 +210,6 @@ export const createTerminalAPI = (): TerminalAPI => ({
listOtherWorktrees: (projectPath: string): Promise<IPCResult<OtherWorktreeInfo[]>> =>
ipcRenderer.invoke(IPC_CHANNELS.TERMINAL_WORKTREE_LIST_OTHER, projectPath),
// Terminal Buffer Persistence
saveTerminalBuffer: (terminalId: string, serializedBuffer: string): Promise<IPCResult> =>
ipcRenderer.invoke(IPC_CHANNELS.TERMINAL_SAVE_BUFFER, terminalId, serializedBuffer),
// Terminal Event Listeners
onTerminalOutput: (
callback: (id: string, data: string) => void
@@ -23,6 +23,7 @@ function CIStatusIcon({ status, className }: CIStatusIconProps) {
return <Loader2 className={cn(baseClasses, 'text-amber-400 animate-spin', className)} />;
case 'failure':
return <XCircle className={cn(baseClasses, 'text-red-400', className)} />;
case 'none':
default:
return <Circle className={cn(baseClasses, 'text-muted-foreground/50', className)} />;
}
@@ -62,6 +63,7 @@ function ReviewStatusBadge({ status, className }: ReviewStatusBadgeProps) {
{t('prStatus.review.pending')}
</Badge>
);
case 'none':
default:
return null;
}
@@ -86,6 +88,7 @@ function MergeReadinessIcon({ state, className }: MergeReadinessIconProps) {
return <AlertTriangle className={cn(baseClasses, 'text-amber-400', className)} />;
case 'blocked':
return <Ban className={cn(baseClasses, 'text-red-400', className)} />;
case 'unknown':
default:
return <HelpCircle className={cn(baseClasses, 'text-muted-foreground/50', className)} />;
}
@@ -166,9 +169,9 @@ export function StatusIndicator({
)}
{/* Merge Readiness */}
{showMergeStatus && mergeKey && mergeableState && (
{showMergeStatus && mergeKey && (
<div className="flex items-center gap-1" title={t(`prStatus.merge.${mergeKey}`)}>
<MergeReadinessIcon state={mergeableState} />
<MergeReadinessIcon state={mergeableState!} />
{!compact && (
<span className="text-xs text-muted-foreground">
{t(`prStatus.merge.${mergeKey}`)}
@@ -1,153 +0,0 @@
/**
* 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<GitLabAutoFixProgress | null>(null);
const [error, setError] = useState<string | null>(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 (
<div className="flex items-center gap-2 text-success text-sm">
<CheckCircle2 className="h-4 w-4" />
<span>{t('gitlab:autoFix.specCreated')}</span>
</div>
);
}
// Show error state
if (error || queueItem?.status === 'failed') {
return (
<div className="space-y-2">
<div className="flex items-center gap-2 text-destructive text-sm">
<AlertCircle className="h-4 w-4" />
<span>{error || queueItem?.error || t('gitlab:autoFix.autoFixFailed')}</span>
</div>
<Button size="sm" variant="outline" onClick={handleStartAutoFix}>
<Wand2 className="h-4 w-4 mr-2" />
{t('gitlab:autoFix.retryAutoFix')}
</Button>
</div>
);
}
// Show progress state
if (isProcessing) {
return (
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
<span>{progress?.message || t('gitlab:autoFix.processing')}</span>
</div>
{progress && (
<Progress value={progress.progress} className="h-1" />
)}
</div>
);
}
// Show button - either highlighted if has auto-fix label, or normal
return (
<Button
size="sm"
variant={hasAutoFixLabel() ? 'default' : 'outline'}
onClick={handleStartAutoFix}
>
<Wand2 className="h-4 w-4 mr-2" />
{t('gitlab:autoFix.autoFix')}
</Button>
);
}
@@ -1,553 +0,0 @@
/**
* 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<void>;
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<Set<number>>(new Set());
// Track which single issues are selected for approval
const [selectedSingleIids, setSelectedSingleIids] = useState<Set<number>>(new Set());
// Track which batches are expanded
const [expandedBatchIds, setExpandedBatchIds] = useState<Set<number>>(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<string | null>(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 = () => (
<div className="flex flex-col items-center justify-center py-8 space-y-6">
<div className="p-4 rounded-full bg-primary/10">
<Layers className="h-12 w-12 text-primary" />
</div>
<div className="text-center space-y-2">
<h3 className="text-lg font-semibold">{t('gitlab:batchReview.title')}</h3>
<p className="text-sm text-muted-foreground max-w-md">
{t('gitlab:batchReview.description')}
</p>
</div>
{(analysisError || approvalError) && (
<div className="flex items-center gap-2 p-3 rounded-lg bg-destructive/10 text-destructive">
<AlertTriangle className="h-4 w-4" />
<span className="text-sm">{analysisError || approvalError}</span>
</div>
)}
<Button onClick={onStartAnalysis} size="lg">
<Layers className="h-4 w-4 mr-2" />
{t('gitlab:batchReview.startAnalysis')}
</Button>
</div>
);
const renderAnalyzing = () => (
<div className="flex flex-col items-center justify-center py-8 space-y-6">
<Loader2 className="h-12 w-12 text-primary animate-spin" />
<div className="text-center space-y-2">
<h3 className="text-lg font-semibold">{t('gitlab:batchReview.analyzing')}</h3>
<p className="text-sm text-muted-foreground">
{analysisProgress?.message || t('gitlab:batchReview.computingSimilarity')}
</p>
</div>
<div className="w-full max-w-md">
<Progress value={analysisProgress?.progress ?? 0} />
<p className="text-xs text-center text-muted-foreground mt-2">
{t('gitlab:batchReview.percentComplete', { value: analysisProgress?.progress ?? 0 })}
</p>
</div>
</div>
);
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 (
<div className="flex flex-col h-[60vh]">
{/* Stats Bar */}
<div className="flex items-center justify-between p-3 bg-muted/50 rounded-lg mb-4">
<div className="flex items-center gap-4 text-sm">
<span>
<strong>{totalIssues}</strong> {t('gitlab:batchReview.issuesAnalyzed')}
</span>
<span className="text-muted-foreground">|</span>
<span>
<strong>{proposedBatches.length}</strong> {t('gitlab:batchReview.batchesProposed')}
</span>
<span className="text-muted-foreground">|</span>
<span>
<strong>{singleIssues.length}</strong> {t('gitlab:batchReview.singleIssues')}
</span>
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={selectAllBatches}>
{t('gitlab:batchReview.selectDeselectAll')}
</Button>
<Button variant="ghost" size="sm" onClick={deselectAllBatches}>
{t('gitlab:batchReview.deselectAll')}
</Button>
</div>
</div>
{/* Batches List */}
<ScrollArea className="flex-1 -mx-6 px-6">
<div className="space-y-3">
{proposedBatches.map((batch, idx) => (
<GitLabBatchCard
key={idx}
batch={batch}
index={idx}
isSelected={selectedBatchIds.has(idx)}
isExpanded={expandedBatchIds.has(idx)}
onToggleSelect={() => toggleBatchSelection(idx)}
onToggleExpand={() => toggleBatchExpanded(idx)}
/>
))}
</div>
{/* Single Issues Section */}
{singleIssues.length > 0 && (
<div className="mt-6">
<h4 className="text-sm font-medium text-muted-foreground mb-2">
{t('gitlab:batchReview.selectSingleIssues')}
</h4>
<div className="grid grid-cols-2 gap-2">
{singleIssues.slice(0, 10).map((issue) => (
<div
key={issue.iid}
onClick={() => 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'
}`}
>
<Checkbox
checked={selectedSingleIids.has(issue.iid)}
className="inline-block mr-2"
onClick={(e) => e.stopPropagation()}
onCheckedChange={() => toggleSingleIssueSelection(issue.iid)}
/>
<span className="text-muted-foreground">#{issue.iid}</span>{' '}
{issue.title}
</div>
))}
{singleIssues.length > 10 && (
<div className="p-2 text-sm text-muted-foreground">
{t('gitlab:batchReview.andMore', { count: singleIssues.length - 10 })}
</div>
)}
</div>
</div>
)}
</ScrollArea>
{/* Selection Summary */}
<div className="flex items-center justify-between pt-4 mt-4 border-t border-border">
<div className="text-sm text-muted-foreground">
{t('gitlab:batchReview.batchesSelected', {
count: selectedCount,
issues: totalIssuesInSelected
})}
{selectedSingleIids.size > 0 && (
<> {t('gitlab:batchReview.plusSingleIssues', { count: selectedSingleIids.size })}</>
)}
</div>
</div>
</div>
);
};
const renderApproving = () => (
<div className="flex flex-col items-center justify-center py-8 space-y-6">
<Loader2 className="h-12 w-12 text-primary animate-spin" />
<div className="text-center space-y-2">
<h3 className="text-lg font-semibold">{t('gitlab:batchReview.creatingBatches')}</h3>
<p className="text-sm text-muted-foreground">
{t('gitlab:batchReview.settingUpBatches')}
</p>
</div>
</div>
);
const renderDone = () => (
<div className="flex flex-col items-center justify-center py-8 space-y-6">
<div className="p-4 rounded-full bg-green-500/10">
<CheckCircle2 className="h-12 w-12 text-green-500" />
</div>
<div className="text-center space-y-2">
<h3 className="text-lg font-semibold">{t('gitlab:batchReview.batchesCreated')}</h3>
<p className="text-sm text-muted-foreground">
{t('gitlab:batchReview.batchesReady')}
</p>
</div>
<Button onClick={onClose}>
{t('gitlab:batchReview.close')}
</Button>
</div>
);
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Layers className="h-5 w-5" />
{t('gitlab:batchReview.title')}
</DialogTitle>
<DialogDescription>
{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')}
</DialogDescription>
</DialogHeader>
<div className="py-4">
{step === 'intro' && renderIntro()}
{step === 'analyzing' && renderAnalyzing()}
{step === 'review' && renderReview()}
{step === 'approving' && renderApproving()}
{step === 'done' && renderDone()}
</div>
{step === 'review' && (
<DialogFooter>
<Button variant="outline" onClick={onClose}>
{t('gitlab:batchReview.cancel')}
</Button>
<Button
onClick={handleApprove}
disabled={(selectedBatchIds.size === 0 && selectedSingleIids.size === 0) || isApproving}
>
{isApproving ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
{t('gitlab:batchReview.creating')}
</>
) : (
<>
<Play className="h-4 w-4 mr-2" />
{t('gitlab:batchReview.approveAndCreate', {
count: selectedBatchIds.size + selectedSingleIids.size
})}
</>
)}
</Button>
</DialogFooter>
)}
</DialogContent>
</Dialog>
);
}
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 (
<div
className={`rounded-lg border transition-colors ${
isSelected
? 'border-primary bg-primary/5'
: 'border-border bg-card'
}`}
>
<div className="flex items-center gap-3 p-3">
<Checkbox
checked={isSelected}
onCheckedChange={onToggleSelect}
/>
<Collapsible className="flex-1" open={isExpanded} onOpenChange={onToggleExpand}>
<div className="flex items-center justify-between">
<CollapsibleTrigger className="flex items-center gap-2 hover:underline">
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
<span className="font-medium text-sm">
{batch.theme || t('gitlab:batchReview.batchNumber', { number: index + 1 })}
</span>
</CollapsibleTrigger>
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-xs">
<Users className="h-3 w-3 mr-1" />
{batch.issueCount} {t('gitlab:batchReview.issues')}
</Badge>
<Badge
variant={batch.validated ? 'default' : 'secondary'}
className="text-xs"
>
{batch.validated ? (
<CheckCircle2 className="h-3 w-3 mr-1" />
) : (
<AlertTriangle className="h-3 w-3 mr-1" />
)}
<span className={confidenceColor}>
{Math.round(batch.confidence * 100)}%
</span>
</Badge>
</div>
</div>
<CollapsibleContent className="mt-3 space-y-2">
{/* Reasoning */}
<p className="text-xs text-muted-foreground px-6">
{batch.reasoning}
</p>
{/* Issues List */}
<div className="space-y-1 px-6">
{batch.issues.map((issue) => (
<div
key={issue.iid}
className="flex items-center justify-between text-sm py-1"
>
<div className="flex items-center gap-2 truncate">
<span className="text-muted-foreground">
#{issue.iid}
</span>
<span className="truncate">{issue.title}</span>
</div>
<span className="text-xs text-muted-foreground">
{t('gitlab:batchReview.similarPercent', { value: Math.round(issue.similarityToPrimary * 100) })}
</span>
</div>
))}
</div>
{/* Themes */}
{batch.commonThemes.length > 0 && (
<div className="flex flex-wrap gap-1 px-6 pt-2">
{batch.commonThemes.map((theme, i) => (
<Badge key={i} variant="secondary" className="text-xs">
{theme}
</Badge>
))}
</div>
)}
</CollapsibleContent>
</Collapsible>
</div>
</div>
);
}
@@ -4,5 +4,3 @@ 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';
@@ -1,57 +0,0 @@
/**
* 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);
});
});
@@ -1,146 +0,0 @@
/**
* 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;
}
@@ -1,568 +0,0 @@
/**
* 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<T extends string>({
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 (
<DropdownMenu open={isOpen} onOpenChange={(open) => {
setIsOpen(open);
if (!open) {
setSearchTerm('');
setFocusedIndex(-1);
}
}}>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className={cn(
"h-8 w-full justify-start border-dashed bg-transparent",
selected.length > 0 && "border-solid bg-accent/50"
)}
>
<Icon className="mr-2 h-4 w-4 text-muted-foreground" />
<span className="truncate">{title}</span>
{selected.length > 0 && (
<>
<Separator orientation="vertical" className="mx-2 h-4" />
<Badge
variant="secondary"
className="rounded-sm px-1 font-normal lg:hidden"
>
{selected.length}
</Badge>
<div className="hidden space-x-1 lg:flex flex-1 truncate">
{selected.length > 2 ? (
<Badge
variant="secondary"
className="rounded-sm px-1 font-normal"
>
{selectedCountLabel}
</Badge>
) : (
renderTrigger ? renderTrigger(selected) : (
selected.map((item) => (
<Badge
variant="secondary"
key={item}
className="rounded-sm px-1 font-normal"
>
{item}
</Badge>
))
)
)}
</div>
</>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-[240px] p-0">
<div className="px-3 py-2 border-b border-border/50">
<div className="text-xs font-semibold text-muted-foreground mb-1">
{title}
</div>
{searchable && (
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3 w-3 text-muted-foreground" />
<Input
placeholder={searchPlaceholder}
className="h-7 text-xs pl-7 bg-muted/50 border-none focus-visible:ring-1 focus-visible:ring-primary/50"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onKeyDown={(e) => e.stopPropagation()}
/>
</div>
)}
</div>
<div
className="max-h-[300px] overflow-y-auto custom-scrollbar p-1"
role="listbox"
aria-multiselectable="true"
onKeyDown={handleKeyDown}
tabIndex={0}
>
{filteredItems.length === 0 ? (
<div className="p-3 text-xs text-muted-foreground text-center">
{noResultsLabel}
</div>
) : (
filteredItems.map((item, index) => {
const isSelected = selected.includes(item);
const isFocused = index === focusedIndex;
return (
<div
key={item}
ref={(el) => { 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}
>
<div className={cn(
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary/30",
isSelected ? "bg-primary border-primary text-primary-foreground" : "opacity-50 [&_svg]:invisible"
)}>
<Check className={cn("h-3 w-3")} />
</div>
{renderItem ? renderItem(item) : item}
</div>
);
})
)}
</div>
{selected.length > 0 && (
<div className="p-1 border-t border-border/50 bg-muted/20">
<Button
variant="ghost"
size="sm"
className="w-full justify-center text-xs h-7 hover:bg-destructive/10 hover:text-destructive"
onClick={() => onChange([])}
>
{clearLabel}
</Button>
</div>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}
/**
* 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 (
<DropdownMenu
open={isOpen}
onOpenChange={(open) => {
setIsOpen(open);
if (open) {
// Focus current selection on open for better keyboard UX
setFocusedIndex(options.findIndex((o) => o.value === value));
} else {
setFocusedIndex(-1);
}
}}
>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-8 justify-start border-dashed bg-transparent"
>
<ArrowUpDown className="mr-2 h-4 w-4 text-muted-foreground" />
<span className="truncate">{title}</span>
<Separator orientation="vertical" className="mx-2 h-4" />
<Badge variant="secondary" className="rounded-sm px-1 font-normal">
{t(currentOption.labelKey)}
</Badge>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-[180px] p-0">
<div className="px-3 py-2 border-b border-border/50">
<div className="text-xs font-semibold text-muted-foreground">
{title}
</div>
</div>
<div
className="p-1"
role="listbox"
tabIndex={0}
onKeyDown={handleKeyDown}
>
{options.map((option, index) => {
const isSelected = value === option.value;
const isFocused = focusedIndex === index;
const Icon = option.icon;
return (
<div
key={option.value}
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",
isSelected && "bg-accent/50",
isFocused && "bg-accent text-accent-foreground"
)}
onClick={() => {
onChange(option.value);
setIsOpen(false);
}}
>
<div className={cn(
"mr-2 flex h-4 w-4 items-center justify-center rounded-full border border-primary/30",
isSelected ? "bg-primary border-primary text-primary-foreground" : "opacity-50"
)}>
{isSelected && <Check className="h-2.5 w-2.5" />}
</div>
<Icon className="mr-2 h-3.5 w-3.5 text-muted-foreground" />
<span>{t(option.labelKey)}</span>
</div>
);
})}
</div>
</DropdownMenuContent>
</DropdownMenu>
);
}
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 (
<div className="px-4 py-2 border-b border-border/40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="flex items-center gap-2 h-9">
{/* Search Input - Flexible width */}
<div className="relative flex-1 max-w-md">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t('mrFiltering.searchPlaceholder')}
value={filters.searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
className="h-8 pl-9 bg-background/50 focus:bg-background transition-colors"
/>
{filters.searchQuery && (
<button
onClick={() => onSearchChange('')}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
aria-label={t('mrFiltering.clearSearch')}
>
<X className="h-3 w-3" />
</button>
)}
</div>
<Separator orientation="vertical" className="h-5 mx-1" />
{/* Contributors Filter */}
<div className="flex-1 max-w-[240px]">
<FilterDropdown
title={t('mrFiltering.contributors')}
icon={Users}
items={contributors}
selected={filters.contributors}
onChange={onContributorsChange}
searchable={true}
searchPlaceholder={t('mrFiltering.searchContributors')}
selectedCountLabel={t('mrFiltering.selectedCount', { count: filters.contributors.length })}
noResultsLabel={t('mrFiltering.noResultsFound')}
clearLabel={t('mrFiltering.clearFilters')}
renderItem={(contributor) => (
<div className="flex items-center gap-2 min-w-0">
<div className="h-5 w-5 rounded-full bg-primary/10 flex items-center justify-center shrink-0">
<span className="text-[10px] font-medium text-primary">
{contributor.slice(0, 2).toUpperCase()}
</span>
</div>
<span className="truncate text-sm">{contributor}</span>
</div>
)}
/>
</div>
{/* Status Filter */}
<div className="flex-1 max-w-[240px]">
<FilterDropdown
title={t('mrFiltering.allStatuses')}
icon={Filter}
items={STATUS_OPTIONS.map((opt) => 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 (
<div className="flex items-center gap-2">
<div className={cn("p-1 rounded-full", option.bgColor)}>
<Icon className={cn("h-3 w-3", option.color)} />
</div>
<span className="text-sm">{t(option.labelKey)}</span>
</div>
);
}}
renderTrigger={(selected) => (
selected.map(status => {
const option = getStatusOption(status);
if (!option) return null;
const Icon = option.icon;
return (
<Badge
variant="secondary"
key={status}
className={cn(
"rounded-sm px-1 font-normal gap-1",
option.bgColor,
option.color
)}
>
<Icon className="h-3 w-3" />
<span className="truncate max-w-[80px]">{t(option.labelKey)}</span>
</Badge>
);
})
)}
/>
</div>
{/* Sort Dropdown */}
<div className="flex-shrink-0">
<SortDropdown
value={filters.sortBy}
onChange={onSortChange}
options={SORT_OPTIONS}
title={t('mrFiltering.sort.label')}
/>
</div>
{/* Reset All */}
{hasActiveFilters && (
<Button
variant="ghost"
size="sm"
onClick={onClearFilters}
className="h-8 px-2 lg:px-3 text-muted-foreground hover:text-foreground ml-auto"
>
<span className="hidden lg:inline mr-2">{t('mrFiltering.reset')}</span>
<X className="h-4 w-4" />
</Button>
)}
</div>
</div>
);
}
@@ -1,732 +0,0 @@
/**
* 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<GitLabMRLogPhase, string> = {
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<GitLabMRLogPhase, typeof FolderOpen> = {
context: FolderOpen,
analysis: BrainCircuit,
synthesis: FileCheck
};
const PHASE_COLORS: Record<GitLabMRLogPhase, string> = {
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<string, string> = {
'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<string, GitLabMRLogEntry[]>();
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<Set<GitLabMRLogPhase>>(new Set(['analysis']));
const [expandedAgents, setExpandedAgents] = useState<Set<string>>(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 (
<div className="h-full overflow-y-auto scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent">
<div className="p-4 space-y-2">
{isLoading && !logs ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : logsAsArray ? (
// Fallback for string[] format (current IPC handler return type)
// TODO: Remove when GITLAB_MR_GET_LOGS returns structured PRLogs
<>
<div className="text-sm text-muted-foreground mb-4">
{t('gitlab:mrFiltering.logs.mrLabel', { iid: mrIid })}
</div>
<div className="space-y-1 text-xs font-mono text-muted-foreground">
{logsAsArray.map((log, idx) => (
<div key={idx} className="py-0.5">{log}</div>
))}
</div>
</>
) : logs ? (
<>
{/* Logs header */}
<div className="flex items-center justify-between mb-4">
<div className="text-sm text-muted-foreground flex items-center gap-2">
{t('gitlab:mrFiltering.logs.mrLabel', { iid: mrIid })}
{logs.is_followup && <Badge variant="outline" className="text-xs">{t('gitlab:mrFiltering.logs.followup')}</Badge>}
{isStreaming && (
<Badge variant="outline" className="text-xs bg-blue-500/10 text-blue-500 border-blue-500/30 flex items-center gap-1">
<Loader2 className="h-2.5 w-2.5 animate-spin" />
{t('gitlab:mrFiltering.logs.live')}
</Badge>
)}
</div>
<div className="text-xs text-muted-foreground flex items-center gap-1">
<Clock className="h-3 w-3" />
{new Date(logs.updated_at).toLocaleString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}
</div>
</div>
{/* Phase-based collapsible logs */}
{(['context', 'analysis', 'synthesis'] as GitLabMRLogPhase[]).map((phase) => (
<PhaseLogSection
key={phase}
phase={phase}
phaseLog={logs.phases[phase]}
isExpanded={expandedPhases.has(phase)}
onToggle={() => togglePhase(phase)}
isStreaming={isStreaming}
expandedAgents={expandedAgents}
onToggleAgent={toggleAgent}
/>
))}
</>
) : isStreaming ? (
<div className="text-center text-sm text-muted-foreground py-8">
<Loader2 className="mx-auto mb-2 h-8 w-8 animate-spin text-blue-500" />
<p>{t('gitlab:mrFiltering.logs.waitingForLogs')}</p>
<p className="text-xs mt-1">{t('gitlab:mrFiltering.logs.reviewStarting')}</p>
</div>
) : (
<div className="text-center text-sm text-muted-foreground py-8">
<Terminal className="mx-auto mb-2 h-8 w-8 opacity-50" />
<p>{t('gitlab:mrFiltering.logs.noLogsAvailable')}</p>
<p className="text-xs mt-1">{t('gitlab:mrFiltering.logs.runReviewGenerateLogs')}</p>
</div>
)}
</div>
</div>
);
}
// Phase Log Section Component
interface PhaseLogSectionProps {
phase: GitLabMRLogPhase;
phaseLog: GitLabMRPhaseLog | null;
isExpanded: boolean;
onToggle: () => void;
isStreaming?: boolean;
expandedAgents: Set<string>;
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 (
<Badge variant="outline" className="text-xs bg-info/10 text-info border-info/30 flex items-center gap-1">
<Loader2 className="h-3 w-3 animate-spin" />
{isStreaming ? t('gitlab:mrFiltering.logs.streaming') : t('gitlab:mrFiltering.logs.running')}
</Badge>
);
}
if (isStreaming && status === 'completed' && !hasEntries) {
return (
<Badge variant="secondary" className="text-xs text-muted-foreground">
{t('gitlab:mrFiltering.logs.pending')}
</Badge>
);
}
switch (status) {
case 'completed':
return (
<Badge variant="outline" className="text-xs bg-success/10 text-success border-success/30 flex items-center gap-1">
<CheckCircle2 className="h-3 w-3" />
{t('gitlab:mrFiltering.logs.complete')}
</Badge>
);
case 'failed':
return (
<Badge variant="outline" className="text-xs bg-destructive/10 text-destructive border-destructive/30 flex items-center gap-1">
<XCircle className="h-3 w-3" />
{t('gitlab:mrFiltering.logs.failed')}
</Badge>
);
default:
return (
<Badge variant="secondary" className="text-xs text-muted-foreground">
{t('gitlab:mrFiltering.logs.pending')}
</Badge>
);
}
};
return (
<Collapsible open={isExpanded} onOpenChange={onToggle}>
<CollapsibleTrigger asChild>
<button
className={cn(
'w-full flex items-center justify-between p-3 rounded-lg border transition-colors',
'hover:bg-secondary/50',
status === 'active' && PHASE_COLORS[phase],
status === 'completed' && 'border-success/30 bg-success/5',
status === 'failed' && 'border-destructive/30 bg-destructive/5',
status === 'pending' && 'border-border bg-secondary/30'
)}
>
<div className="flex items-center gap-2">
{isExpanded ? (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
<Icon className={cn('h-4 w-4', status === 'active' ? PHASE_COLORS[phase].split(' ')[0] : 'text-muted-foreground')} />
<span className="font-medium text-sm">{getPhaseLabel(phase, t)}</span>
{hasEntries && (
<span className="text-xs text-muted-foreground">
({phaseLog?.entries.length} {t('gitlab:mrFiltering.logs.entries')})
</span>
)}
</div>
<div className="flex items-center gap-2">
{getStatusBadge()}
</div>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="mt-1 ml-6 border-l-2 border-border pl-4 py-2 space-y-2">
{!hasEntries ? (
<p className="text-xs text-muted-foreground italic">{t('gitlab:mrFiltering.logs.noLogsYet')}</p>
) : (
<GroupedLogEntries
entries={phaseLog?.entries || []}
phase={phase}
expandedAgents={expandedAgents}
onToggleAgent={onToggleAgent}
/>
)}
</div>
</CollapsibleContent>
</Collapsible>
);
}
// Grouped Log Entries Component
interface GroupedLogEntriesProps {
entries: GitLabMRLogEntry[];
phase: GitLabMRLogPhase;
expandedAgents: Set<string>;
onToggleAgent: (agentKey: string) => void;
}
function GroupedLogEntries({ entries, phase, expandedAgents, onToggleAgent }: GroupedLogEntriesProps) {
const { agentGroups, orchestratorActivity, otherEntries } = groupEntriesByAgent(entries);
return (
<div className="space-y-2">
{otherEntries.length > 0 && (
<div className="space-y-1">
{otherEntries.map((entry, idx) => (
<LogEntry key={`other-${entry.timestamp}-${idx}`} entry={entry} />
))}
</div>
)}
{orchestratorActivity.length > 0 && (
<OrchestratorActivitySection
entries={orchestratorActivity}
phase={phase}
isExpanded={expandedAgents.has(`${phase}-orchestrator-activity`)}
onToggle={() => onToggleAgent(`${phase}-orchestrator-activity`)}
/>
)}
{agentGroups.map((group) => (
<AgentLogGroup
key={`${phase}-${group.agentName}`}
group={group}
phase={phase}
isExpanded={expandedAgents.has(`${phase}-${group.agentName}`)}
onToggle={() => onToggleAgent(`${phase}-${group.agentName}`)}
/>
))}
</div>
);
}
// 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 (
<div className="rounded-md border border-border/50 bg-secondary/10 overflow-hidden">
<button
onClick={onToggle}
className={cn(
'w-full flex items-center justify-between p-2 transition-colors',
'hover:bg-secondary/30',
isExpanded && 'bg-secondary/20'
)}
>
<div className="flex items-center gap-2">
{isExpanded ? (
<ChevronDown className="h-3 w-3 text-muted-foreground" />
) : (
<ChevronRight className="h-3 w-3 text-muted-foreground" />
)}
<Activity className="h-3 w-3 text-orange-400" />
<span className="text-xs text-muted-foreground">{t('gitlab:mrFiltering.logs.agentActivity')}</span>
</div>
<Badge variant="outline" className="text-[9px] px-1.5 py-0 bg-orange-500/10 text-orange-400 border-orange-500/30">
{summary}
</Badge>
</button>
{isExpanded && (
<div className="border-t border-border/30 p-2 space-y-0.5 max-h-[300px] overflow-y-auto">
{entries.map((entry, idx) => (
<div key={`activity-${entry.timestamp}-${idx}`} className="flex items-start gap-2 text-[10px] text-muted-foreground/80 py-0.5">
<span className="text-muted-foreground/50 tabular-nums shrink-0">
{new Date(entry.timestamp).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
</span>
<span className="break-words">{entry.content}</span>
</div>
))}
</div>
)}
</div>
);
}
// 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 (
<div className="rounded-md border border-border/50 bg-secondary/20 overflow-hidden">
<div className="p-2 space-y-1">
<div className="flex items-center justify-between">
<Badge
variant="outline"
className={cn('text-[10px] px-1.5 py-0.5', getSourceColor(agentName))}
>
{displayName}
</Badge>
{hasMoreEntries && (
<button
onClick={onToggle}
className={cn(
'flex items-center gap-1 text-[10px] px-2 py-0.5 rounded transition-colors',
'text-muted-foreground hover:text-foreground hover:bg-secondary/50',
isExpanded && 'bg-secondary/50 text-foreground'
)}
>
{isExpanded ? (
<>
<ChevronDown className="h-3 w-3" />
<span>{t('gitlab:mrFiltering.logs.hideMore', { count: otherEntries.length })}</span>
</>
) : (
<>
<ChevronRight className="h-3 w-3" />
<span>{t('gitlab:mrFiltering.logs.showMore', { count: otherEntries.length })}</span>
</>
)}
</button>
)}
</div>
{summaryEntry && (
<LogEntry entry={{ ...summaryEntry, source: undefined }} />
)}
</div>
{hasMoreEntries && isExpanded && (
<div className="border-t border-border/30 bg-secondary/10 p-2 space-y-1">
{otherEntries.map((entry, idx) => (
<LogEntry key={`${entry.timestamp}-${idx}`} entry={{ ...entry, source: undefined }} />
))}
</div>
)}
</div>
);
}
// 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 (
<div className="flex flex-col">
<div className="flex items-start gap-2 text-xs text-destructive bg-destructive/10 rounded-md px-2 py-1">
<XCircle className="h-3 w-3 mt-0.5 shrink-0" />
<span className="break-words flex-1">{entry.content}</span>
{hasDetail && (
<button
onClick={() => setIsExpanded(!isExpanded)}
className={cn(
'flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded shrink-0',
'text-muted-foreground hover:text-foreground hover:bg-secondary/50 transition-colors',
isExpanded && 'bg-secondary/50'
)}
>
{isExpanded ? (
<>
<ChevronDown className="h-2.5 w-2.5" />
<span>{t('gitlab:mrFiltering.logs.less')}</span>
</>
) : (
<>
<ChevronRight className="h-2.5 w-2.5" />
<span>{t('gitlab:mrFiltering.logs.more')}</span>
</>
)}
</button>
)}
</div>
{hasDetail && isExpanded && (
<div className="mt-1.5 ml-4 p-2 bg-destructive/5 rounded-md border border-destructive/20 overflow-x-auto">
<pre className="text-[10px] text-destructive/80 whitespace-pre-wrap break-words font-mono max-h-[300px] overflow-y-auto">
{entry.detail}
</pre>
</div>
)}
</div>
);
}
if (entry.type === 'success') {
return (
<div className="flex items-start gap-2 text-xs text-success bg-success/10 rounded-md px-2 py-1">
<CheckCircle2 className="h-3 w-3 mt-0.5 shrink-0" />
<span className="break-words flex-1">{entry.content}</span>
</div>
);
}
if (entry.type === 'info') {
return (
<div className="flex items-start gap-2 text-xs text-info bg-info/10 rounded-md px-2 py-1">
<Info className="h-3 w-3 mt-0.5 shrink-0" />
<span className="break-words flex-1">{entry.content}</span>
</div>
);
}
return (
<div className="flex flex-col">
<div className="flex items-start gap-2 text-xs text-muted-foreground py-0.5">
<span className="text-[10px] text-muted-foreground/60 tabular-nums shrink-0">
{formatTime(entry.timestamp)}
</span>
{entry.source && (
<Badge variant="outline" className={cn('text-[9px] px-1 py-0 shrink-0', getSourceColor(entry.source))}>
{entry.source}
</Badge>
)}
<span className="break-words whitespace-pre-wrap flex-1">{entry.content}</span>
{hasDetail && (
<button
onClick={() => setIsExpanded(!isExpanded)}
className={cn(
'flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded shrink-0',
'text-muted-foreground hover:text-foreground hover:bg-secondary/50 transition-colors',
isExpanded && 'bg-secondary/50'
)}
>
{isExpanded ? (
<>
<ChevronDown className="h-2.5 w-2.5" />
<span>{t('gitlab:mrFiltering.logs.less')}</span>
</>
) : (
<>
<ChevronRight className="h-2.5 w-2.5" />
<span>{t('gitlab:mrFiltering.logs.more')}</span>
</>
)}
</button>
)}
</div>
{hasDetail && isExpanded && (
<div className="mt-1.5 ml-12 p-2 bg-secondary/30 rounded-md border border-border/50 overflow-x-auto">
<pre className="text-[10px] text-muted-foreground whitespace-pre-wrap break-words font-mono max-h-[300px] overflow-y-auto">
{entry.detail}
</pre>
</div>
)}
</div>
);
}
@@ -1,246 +0,0 @@
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 <CheckCircle2 className={cn(baseClasses, 'text-emerald-400', className)} />;
case 'pending':
return <Loader2 className={cn(baseClasses, 'text-amber-400 animate-spin', className)} />;
case 'failure':
return <XCircle className={cn(baseClasses, 'text-red-400', className)} />;
default:
return <Circle className={cn(baseClasses, 'text-muted-foreground/50', className)} />;
}
}
/**
* Review Status Badge Component
* Displays a badge representing the review status
*/
interface ReviewStatusBadgeProps {
status: ReviewsStatus;
className?: string;
}
function ReviewStatusBadge({ status, className }: ReviewStatusBadgeProps) {
const { t } = useTranslation('common');
switch (status) {
case 'approved':
return (
<Badge variant="success" className={cn('gap-1', className)}>
<CheckCircle2 className="h-3 w-3" />
{t('prStatus.review.approved')}
</Badge>
);
case 'changes_requested':
return (
<Badge variant="destructive" className={cn('gap-1', className)}>
<AlertTriangle className="h-3 w-3" />
{t('prStatus.review.changesRequested')}
</Badge>
);
case 'pending':
return (
<Badge variant="warning" className={cn('gap-1', className)}>
<Circle className="h-3 w-3" />
{t('prStatus.review.pending')}
</Badge>
);
default:
return null;
}
}
/**
* Merge Readiness Icon Component
* Displays an icon representing the merge readiness state
*/
interface MergeReadinessIconProps {
state: MergeableState;
className?: string;
}
function MergeReadinessIcon({ state, className }: MergeReadinessIconProps) {
const baseClasses = 'h-4 w-4';
switch (state) {
case 'clean':
return <GitMerge className={cn(baseClasses, 'text-emerald-400', className)} />;
case 'dirty':
return <AlertTriangle className={cn(baseClasses, 'text-amber-400', className)} />;
case 'blocked':
return <HelpCircle className={cn(baseClasses, 'text-red-400', className)} />;
default:
return <HelpCircle className={cn(baseClasses, 'text-muted-foreground/50', className)} />;
}
}
/**
* 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<string, string> = {
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<string, MergeableState> = {
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 (
<div className={cn('flex items-center gap-2', className)}>
{/* CI Status */}
{showChecks && (
<div className="flex items-center gap-1" title={t(`mrStatus.ci.${checksStatus}`)}>
<CIStatusIcon status={checksStatus!} />
{!compact && (
<span className="text-xs text-muted-foreground">
{t(`mrStatus.ci.${checksStatus}`)}
</span>
)}
</div>
)}
{/* Review Status */}
{showReviews && (
compact ? (
<ReviewStatusBadge status={reviewsStatus!} className="px-1.5 py-0" />
) : (
<ReviewStatusBadge status={reviewsStatus!} />
)
)}
{/* Merge Readiness */}
{showMergeStatus && mergeKey && mergeableState && (
<div className="flex items-center gap-1" title={t(`mrStatus.merge.${mergeKey}`)}>
<MergeReadinessIcon state={mergeableState} />
{!compact && (
<span className="text-xs text-muted-foreground">
{t(`mrStatus.merge.${mergeKey}`)}
</span>
)}
</div>
)}
</div>
);
}
/**
* Compact Status Indicator
*
* A minimal version showing just icons with tooltips.
* Useful for tight spaces in the MR list.
*/
export function CompactMRStatusIndicator(props: Omit<MRStatusIndicatorProps, 'compact'>) {
return <MRStatusIndicator {...props} compact />;
}
// Re-export sub-components for flexibility
export { CIStatusIcon, ReviewStatusBadge, MergeReadinessIcon };
@@ -6,6 +6,3 @@ 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';
@@ -1,3 +1,2 @@
export * from './useGitLabMRs';
export * from './useFindingSelection';
export * from './useGitLabMRFiltering';
@@ -1,251 +0,0 @@
/**
* 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<GitLabMRFilterState>(DEFAULT_FILTERS);
// Derive unique contributors from MRs
const contributors = useMemo(() => {
const authorSet = new Set<string>();
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,
};
}
@@ -50,15 +50,6 @@ interface UseGitLabMRsResult {
error: string | null;
newCommitsCheck: GitLabNewCommitsCheck | null;
} | null;
listMoreMRs: (page?: number) => Promise<boolean>;
deleteReview: (mrIid: number, noteId: number) => Promise<boolean>;
checkMergeReadiness: (mrIid: number) => Promise<{
canMerge: boolean;
hasConflicts: boolean;
needsDiscussion: boolean;
pipelineStatus?: string;
} | null>;
getLogs: (mrIid: number) => Promise<string[] | null>;
}
export function useGitLabMRs(projectId?: string, options: UseGitLabMRsOptions = {}): UseGitLabMRsResult {
@@ -71,6 +62,7 @@ export function useGitLabMRs(projectId?: string, options: UseGitLabMRsOptions =
const [projectPath, setProjectPath] = useState<string | null>(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);
@@ -287,77 +279,6 @@ export function useGitLabMRs(projectId?: string, options: UseGitLabMRsOptions =
}
}, [projectId]);
// NEW: Additional methods for feature parity
const listMoreMRs = useCallback(async (page: number = 2): Promise<boolean> => {
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<boolean> => {
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<string[] | null> => {
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,
@@ -382,9 +303,5 @@ export function useGitLabMRs(projectId?: string, options: UseGitLabMRsOptions =
assignMR,
approveMR,
getReviewStateForMR,
listMoreMRs,
deleteReview,
checkMergeReadiness,
getLogs,
};
}
@@ -156,12 +156,7 @@ export function GraphitiStep({ onNext, onBack, onSkip }: GraphitiStepProps) {
setIsCheckingInfra(true);
try {
const result = await window.electronAPI.getMemoryInfrastructureStatus();
if (result?.success && result?.data && typeof result.data === 'object') {
const data = result.data as { memory?: { kuzuInstalled?: boolean } };
setKuzuAvailable(!!data.memory?.kuzuInstalled);
} else {
setKuzuAvailable(false);
}
setKuzuAvailable(!!(result?.success && result?.data?.memory?.kuzuInstalled ));
} catch {
setKuzuAvailable(false);
} finally {
@@ -238,18 +233,28 @@ 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 && typeof result.data === 'object') {
const data = result.data as { success?: boolean; message?: string };
if (result?.success && result?.data) {
setValidationStatus({
database: {
tested: true,
success: data.success ?? false,
message: data.message || 'Connection test completed'
success: result.data.success,
message: result.data.message
},
provider: {
tested: true,
@@ -258,8 +263,8 @@ export function GraphitiStep({ onNext, onBack, onSkip }: GraphitiStepProps) {
}
});
if (data.success === false) {
setError(`Database: ${data.message || 'Connection failed'}`);
if (!result.data.success) {
setError(`Database: ${result.data.message}`);
}
} else {
setError(result?.error || 'Failed to test connection');
+4 -15
View File
@@ -27,12 +27,8 @@ 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 = {
const browserMockAPI: ElectronAPI = {
// Project Operations
...projectMock,
@@ -322,11 +318,7 @@ const browserMockAPI = {
startStatusPolling: async () => true,
stopStatusPolling: async () => true,
getPollingMetadata: async () => null,
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: '' } })
onPRStatusUpdate: () => () => {}
},
// Queue Routing API (rate limit recovery)
@@ -460,7 +452,7 @@ const browserMockAPI = {
copyDebugInfo: async () => ({ success: false, error: 'Not available in browser mode' }),
getRecentErrors: async () => [],
listLogFiles: async () => []
} satisfies Partial<ElectronAPI>;
};
/**
* Initialize browser mock if not running in Electron
@@ -468,10 +460,7 @@ const browserMockAPI = {
export function initBrowserMock(): void {
if (!isElectron) {
console.warn('%c[Browser Mock] Initializing mock electronAPI for browser preview', 'color: #f0ad4e; font-weight: bold;');
// 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<ElectronAPI> for type safety
(window as Window & { electronAPI: ElectronAPI }).electronAPI = browserMockAPI as unknown as ElectronAPI;
(window as Window & { electronAPI: ElectronAPI }).electronAPI = browserMockAPI;
}
}
@@ -134,9 +134,8 @@ export const changelogMock = {
}
}),
createRelease: async () => {
createRelease: () => {
console.warn('[Browser Mock] createRelease called');
return { success: true, data: { url: '', tagName: '' } };
},
onReleaseProgress: () => () => {},
@@ -125,33 +125,5 @@ 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 }
}
})
};
@@ -82,7 +82,7 @@ export const terminalMock = {
}
}),
saveTerminalBuffer: async (_terminalId: string, _serializedBuffer: string) => ({ success: true }),
saveTerminalBuffer: async () => {},
checkTerminalPtyAlive: async () => ({
success: true,
@@ -107,28 +107,11 @@ export async function loadProjectContext(projectId: string): Promise<void> {
try {
const result = await window.electronAPI.getProjectContext(projectId);
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([]);
}
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 || []);
} else {
store.setIndexError(result.error || 'Failed to load project context');
}
@@ -150,8 +133,8 @@ export async function refreshProjectIndex(projectId: string): Promise<void> {
try {
const result = await window.electronAPI.refreshProjectIndex(projectId);
if (result.success && result.data && typeof result.data === 'object') {
store.setProjectIndex(result.data as ProjectIndex);
if (result.success && result.data) {
store.setProjectIndex(result.data);
} else {
store.setIndexError(result.error || 'Failed to refresh project index');
}
@@ -181,8 +164,8 @@ export async function searchMemories(
try {
const result = await window.electronAPI.searchMemories(projectId, query);
if (result.success && result.data && Array.isArray(result.data)) {
store.setSearchResults(result.data as ContextSearchResult[]);
if (result.success && result.data) {
store.setSearchResults(result.data);
} else {
store.setSearchResults([]);
}
@@ -205,8 +188,8 @@ export async function loadRecentMemories(
try {
const result = await window.electronAPI.getRecentMemories(projectId, limit);
if (result.success && result.data && Array.isArray(result.data)) {
store.setRecentMemories(result.data as RendererMemory[]);
if (result.success && result.data) {
store.setRecentMemories(result.data);
}
} catch (_error) {
// Silently fail - memories are optional
@@ -1,81 +0,0 @@
/**
* 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');
});
});
@@ -1,109 +0,0 @@
/**
* 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> = {}): 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);
});
});
@@ -1,138 +0,0 @@
/**
* 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');
});
});
});
@@ -4,38 +4,14 @@
* 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,
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';
import { initializeMRReviewListeners as _initMRReviewListeners } from './mr-review-store';
/**
* Initialize all global GitLab listeners.
@@ -46,15 +22,6 @@ 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,
@@ -63,6 +30,5 @@ export type {
GitLabMergeRequest,
GitLabSyncStatus,
GitLabInvestigationStatus,
GitLabInvestigationResult,
GitLabIssue
GitLabInvestigationResult
} from '../../../shared/types';
@@ -1,62 +0,0 @@
/**
* 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<InvestigationState>((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);
}
@@ -1,173 +0,0 @@
/**
* 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<GitLabFilterState, 'merged'>;
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<GitLabIssue>) => 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<IssuesState>((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<void> {
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<boolean> {
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);
}
}
@@ -1,87 +0,0 @@
/**
* 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<SyncStatusState>((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<GitLabSyncStatus | null> {
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;
}
}
@@ -95,13 +95,12 @@ export async function loadReleaseableVersions(projectId: string): Promise<void>
try {
const result = await window.electronAPI.getReleaseableVersions(projectId);
if (result.success && result.data && Array.isArray(result.data)) {
const versions = result.data as ReleaseableVersion[];
store.setReleaseableVersions(versions);
if (result.success && result.data) {
store.setReleaseableVersions(result.data);
// Auto-select first unreleased version if none selected
if (!store.selectedVersion) {
const firstUnreleased = versions.find((v: ReleaseableVersion) => !v.isReleased);
const firstUnreleased = result.data.find((v: ReleaseableVersion) => !v.isReleased);
if (firstUnreleased) {
store.setSelectedVersion(firstUnreleased.version);
}
@@ -133,8 +132,8 @@ export async function runPreflightCheck(projectId: string): Promise<void> {
try {
const result = await window.electronAPI.runReleasePreflightCheck(projectId, version);
if (result.success && result.data && typeof result.data === 'object' && !Array.isArray(result.data)) {
store.setPreflightStatus(result.data as ReleasePreflightStatus);
if (result.success && result.data) {
store.setPreflightStatus(result.data);
} else {
store.setError(result.error || 'Failed to run pre-flight checks');
}
-17
View File
@@ -76,7 +76,6 @@ 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',
@@ -334,22 +333,11 @@ 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',
@@ -481,11 +469,6 @@ 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',
@@ -71,6 +71,12 @@ export const ALL_AVAILABLE_MODELS: ModelOption[] = [
{ value: 'glm-4.7', label: 'GLM-4.7', provider: 'zai', description: 'Previous flagship', capabilities: { thinking: false, tools: true, vision: false, contextWindow: 128000 } },
{ value: 'glm-4.6v', label: 'GLM-4.6V', provider: 'zai', description: 'Multimodal', capabilities: { thinking: false, tools: true, vision: true, contextWindow: 128000 } },
{ value: 'glm-4.5-flash', label: 'GLM-4.5 Flash', provider: 'zai', description: 'Fast', capabilities: { thinking: false, tools: true, vision: false, contextWindow: 128000 } },
// Mascarade (LLM Orchestration Engine)
{ value: 'mascarade-router', label: 'Mascarade Router', provider: 'mascarade', description: 'Multi-LLM routing (best/cheapest/fastest)', capabilities: { thinking: false, tools: true, vision: false, contextWindow: 128000 } },
{ value: 'mascarade-writer', label: 'Mascarade Writer', provider: 'mascarade', description: 'Content generation agent', capabilities: { thinking: false, tools: true, vision: false, contextWindow: 128000 } },
{ value: 'mascarade-coder', label: 'Mascarade Coder', provider: 'mascarade', description: 'Code review & generation', capabilities: { thinking: false, tools: true, vision: false, contextWindow: 128000 } },
{ value: 'mascarade-analyst', label: 'Mascarade Analyst', provider: 'mascarade', description: 'Data analysis agent', capabilities: { thinking: false, tools: true, vision: false, contextWindow: 128000 } },
{ value: 'mascarade-planner', label: 'Mascarade Planner', provider: 'mascarade', description: 'Project decomposition', capabilities: { thinking: false, tools: true, vision: false, contextWindow: 128000 } },
];
// Maps model shorthand to actual Claude model IDs
@@ -322,6 +328,12 @@ export const PROVIDER_PRESET_DEFINITIONS: Partial<Record<BuiltinProvider, Record
balanced: { primaryModel: '', primaryThinking: 'low', phaseModels: { spec: '', planning: '', coding: '', qa: '' }, phaseThinking: { spec: 'low', planning: 'low', coding: 'low', qa: 'low' } },
quick: { primaryModel: '', primaryThinking: 'low', phaseModels: { spec: '', planning: '', coding: '', qa: '' }, phaseThinking: { spec: 'low', planning: 'low', coding: 'low', qa: 'low' } },
},
mascarade: {
auto: { primaryModel: 'mascarade-router', primaryThinking: 'low', phaseModels: { spec: 'mascarade-router', planning: 'mascarade-router', coding: 'mascarade-router', qa: 'mascarade-router' }, phaseThinking: { spec: 'low', planning: 'low', coding: 'low', qa: 'low' } },
complex: { primaryModel: 'mascarade-router', primaryThinking: 'low', phaseModels: { spec: 'mascarade-router', planning: 'mascarade-router', coding: 'mascarade-router', qa: 'mascarade-router' }, phaseThinking: { spec: 'low', planning: 'low', coding: 'low', qa: 'low' } },
balanced: { primaryModel: 'mascarade-router', primaryThinking: 'low', phaseModels: { spec: 'mascarade-router', planning: 'mascarade-router', coding: 'mascarade-router', qa: 'mascarade-router' }, phaseThinking: { spec: 'low', planning: 'low', coding: 'low', qa: 'low' } },
quick: { primaryModel: 'mascarade-router', primaryThinking: 'low', phaseModels: { spec: 'mascarade-router', planning: 'mascarade-router', coding: 'mascarade-router', qa: 'mascarade-router' }, phaseThinking: { spec: 'low', planning: 'low', coding: 'low', qa: 'low' } },
},
};
/**
@@ -73,4 +73,11 @@ export const PROVIDER_REGISTRY: ProviderInfo[] = [
authMethods: ['api-key'], envVars: [],
configFields: ['baseUrl'],
},
{
id: 'mascarade', name: 'Mascarade', description: 'LLM Orchestration Engine — multi-provider routing with 19+ agents',
category: 'local',
authMethods: ['api-key'], envVars: ['MASCARADE_API_KEY'],
configFields: ['baseUrl'],
website: 'https://mascarade.saillant.cc',
},
];
@@ -204,104 +204,5 @@
"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"
}
}
}
@@ -214,104 +214,5 @@
"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"
}
}
}
@@ -1,95 +0,0 @@
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);
});
});
@@ -1,46 +0,0 @@
/**
* 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<T extends Filterable>(
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);
}
@@ -1,42 +0,0 @@
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);
});
});
@@ -1,35 +0,0 @@
/**
* 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<T>(
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
};
}
@@ -1,31 +0,0 @@
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');
});
});
@@ -1,48 +0,0 @@
/**
* 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;
@@ -324,7 +324,6 @@ 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;
@@ -376,27 +375,6 @@ 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;
@@ -500,19 +478,3 @@ 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;
}
+1 -14
View File
@@ -170,12 +170,6 @@ 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[];
@@ -183,14 +177,7 @@ export interface TabState {
tabOrder: string[];
}
// 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 {
export interface ElectronAPI {
// Project operations
addProject: (projectPath: string) => Promise<IPCResult<Project>>;
removeProject: (projectId: string) => Promise<IPCResult>;
@@ -7,7 +7,7 @@ export type CredentialSource = 'oauth' | 'api-key' | 'env' | 'keychain';
export type BuiltinProvider =
| 'anthropic' | 'openai' | 'google' | 'amazon-bedrock' | 'azure'
| 'mistral' | 'groq' | 'xai' | 'openrouter' | 'zai'
| 'ollama' | 'openai-compatible';
| 'ollama' | 'openai-compatible' | 'mascarade';
export type BillingModel = 'subscription' | 'pay-per-use';