Compare commits
66 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 92b7534db6 | |||
| 86dadb3981 | |||
| d6360e492c | |||
| dcdd75376d | |||
| 48f8396bdd | |||
| 33107fcd91 | |||
| eb20eed304 | |||
| 8f3ea8f311 | |||
| 7da21d131e | |||
| 7705c563bb | |||
| 13a48e26e1 | |||
| dcc6afad2d | |||
| 7896be5f69 | |||
| be83ad46f6 | |||
| 5eac6d8d0f | |||
| 7a1db877f9 | |||
| 7a39253214 | |||
| 60bdf51aef | |||
| b437dc6f30 | |||
| 2e56176514 | |||
| dfa08dc588 | |||
| a8f7519a53 | |||
| 302233b31c | |||
| 80170562a4 | |||
| 4e2a614112 | |||
| db5eff4b9b | |||
| b188fed7e2 | |||
| 7fd0b4be38 | |||
| de04b81d60 | |||
| f2d2b0b680 | |||
| 8b279bf1ff | |||
| 9492440460 | |||
| 86da9ee6dd | |||
| 228a58a87c | |||
| af4c7fe89e | |||
| cd3fb11239 | |||
| 5d50a87d3c | |||
| d45c2033fc | |||
| 5236d6285b | |||
| 186940964a | |||
| 94621fde2b | |||
| f64d5decd1 | |||
| 0d727a3b91 | |||
| 7af1c57e20 | |||
| be78dc6849 | |||
| fb6c666706 | |||
| 35585053cd | |||
| 74d95d14a9 | |||
| efce7cdd46 | |||
| d5a276ebfa | |||
| 532458546f | |||
| 63c608b38f | |||
| a91163200d | |||
| 571bf51b33 | |||
| e4c517c09f | |||
| 11ed33569c | |||
| f21610a8b2 | |||
| 011a24239e | |||
| 9a11866f12 | |||
| 13c07f0a51 | |||
| b470b367bf | |||
| 42469a75df | |||
| 11299f59cb | |||
| c14e2fbaa2 | |||
| 8e29eb03ec | |||
| 53d24262e1 |
@@ -45,7 +45,8 @@
|
||||
"noProcessEnv": "off",
|
||||
"useNodejsImportProtocol": "off",
|
||||
"useImportType": "off",
|
||||
"useTemplate": "off"
|
||||
"useTemplate": "off",
|
||||
"noNonNullAssertion": "warn"
|
||||
},
|
||||
"suspicious": {
|
||||
"recommended": true,
|
||||
|
||||
@@ -7,17 +7,20 @@ import { EventEmitter } from 'events';
|
||||
// Mock app
|
||||
export const app = {
|
||||
getPath: vi.fn((name: string) => {
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const paths: Record<string, string> = {
|
||||
userData: '/tmp/test-app-data',
|
||||
home: '/tmp/test-home',
|
||||
temp: '/tmp'
|
||||
userData: path.join(os.homedir(), '.aperant-test-data'),
|
||||
home: os.homedir(),
|
||||
temp: os.tmpdir()
|
||||
};
|
||||
return paths[name] || '/tmp';
|
||||
return paths[name] || path.join(os.homedir(), '.aperant-test');
|
||||
}),
|
||||
getAppPath: vi.fn(() => '/tmp/test-app'),
|
||||
getVersion: vi.fn(() => '0.1.0'),
|
||||
isPackaged: false,
|
||||
on: vi.fn(),
|
||||
whenReady: vi.fn(() => Promise.resolve()),
|
||||
quit: vi.fn()
|
||||
};
|
||||
|
||||
|
||||
@@ -209,7 +209,6 @@ describe('Claude Profile IPC Integration', () => {
|
||||
|
||||
await handleProfileSave?.(null, profile);
|
||||
|
||||
// biome-ignore lint/style/noNonNullAssertion: Test file - configDir is set in createTestProfile
|
||||
expect(existsSync(profile.configDir!)).toBe(true);
|
||||
});
|
||||
|
||||
@@ -224,7 +223,6 @@ describe('Claude Profile IPC Integration', () => {
|
||||
|
||||
await handleProfileSave?.(null, profile);
|
||||
|
||||
// biome-ignore lint/style/noNonNullAssertion: Test file - configDir is set in createTestProfile
|
||||
expect(existsSync(profile.configDir!)).toBe(false);
|
||||
});
|
||||
|
||||
|
||||
@@ -89,6 +89,10 @@ describe('resolveModelId', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
// Clear model override env vars to ensure consistent test results
|
||||
delete process.env.ANTHROPIC_DEFAULT_OPUS_MODEL;
|
||||
delete process.env.ANTHROPIC_DEFAULT_SONNET_MODEL;
|
||||
delete process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -499,7 +499,6 @@ export class ChangelogService extends EventEmitter {
|
||||
this.debug('Error in AI version suggestion, falling back to patch bump', error);
|
||||
// Fallback to patch bump if AI fails
|
||||
// currentVersion is guaranteed non-empty: the try block returns early if falsy or invalid
|
||||
// biome-ignore lint/style/noNonNullAssertion: guarded by early returns in try block
|
||||
const [major, minor, patch] = currentVersion!.split('.').map(Number);
|
||||
return {
|
||||
version: `${major}.${minor}.${patch + 1}`,
|
||||
|
||||
@@ -385,6 +385,16 @@ function createWindow(): void {
|
||||
agentManager?.killAll?.()?.catch((err: unknown) => {
|
||||
console.warn('[main] Error killing agents on window close:', err);
|
||||
});
|
||||
// Clear GitLab MR polling intervals for all projects
|
||||
import('./ipc-handlers/gitlab/mr-review-handlers').then(({ clearPollingForProject }) => {
|
||||
const { projectStore } = require('./project-store');
|
||||
const projects = projectStore.getProjects();
|
||||
for (const project of projects) {
|
||||
clearPollingForProject(project.id);
|
||||
}
|
||||
}).catch((err: unknown) => {
|
||||
console.warn('[main] Error clearing GitLab polling on window close:', err);
|
||||
});
|
||||
mainWindow = null;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -304,9 +304,10 @@ function sendProgress(
|
||||
function sendError(
|
||||
mainWindow: BrowserWindow,
|
||||
projectId: string,
|
||||
issueIid: number,
|
||||
error: string
|
||||
): void {
|
||||
mainWindow.webContents.send(IPC_CHANNELS.GITLAB_AUTOFIX_ERROR, projectId, error);
|
||||
mainWindow.webContents.send(IPC_CHANNELS.GITLAB_AUTOFIX_ERROR, projectId, issueIid, error);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -498,7 +499,7 @@ export function registerAutoFixHandlers(
|
||||
});
|
||||
} catch (error) {
|
||||
debugLog('Auto-fix failed', { issueIid, error: error instanceof Error ? error.message : error });
|
||||
sendError(mainWindow, projectId, error instanceof Error ? error.message : 'Failed to start auto-fix');
|
||||
sendError(mainWindow, projectId, issueIid, error instanceof Error ? error.message : 'Failed to start auto-fix');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -10,15 +10,14 @@
|
||||
* 6. Approve MR
|
||||
*/
|
||||
|
||||
import { ipcMain } from 'electron';
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import { ipcMain, BrowserWindow } from 'electron';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { IPC_CHANNELS, MODEL_ID_MAP, DEFAULT_FEATURE_MODELS, DEFAULT_FEATURE_THINKING } from '../../../shared/constants';
|
||||
import { getGitLabConfig, gitlabFetch, encodeProjectPath } from './utils';
|
||||
import { readSettingsFile } from '../../settings-utils';
|
||||
import type { Project, AppSettings } from '../../../shared/types';
|
||||
import type { Project, AppSettings, IPCResult, GitLabMergeRequest } from '../../../shared/types';
|
||||
import type {
|
||||
MRReviewResult,
|
||||
MRReviewProgress,
|
||||
@@ -26,6 +25,7 @@ import type {
|
||||
} from './types';
|
||||
import { createContextLogger } from '../github/utils/logger';
|
||||
import { withProjectOrNull } from '../github/utils/project-middleware';
|
||||
import { projectStore } from '../../project-store';
|
||||
import { createIPCCommunicators } from '../github/utils/ipc-communicator';
|
||||
import {
|
||||
MRReviewEngine,
|
||||
@@ -47,6 +47,27 @@ const REBASE_POLL_INTERVAL_MS = 1000;
|
||||
// Default rebase timeout (60 seconds). Can be overridden via GITLAB_REBASE_TIMEOUT_MS env var
|
||||
const REBASE_TIMEOUT_MS = parseInt(process.env.GITLAB_REBASE_TIMEOUT_MS || '60000', 10);
|
||||
|
||||
/**
|
||||
* Registry of status polling intervals for MR updates
|
||||
* Key format: `${projectId}:${mrIid}`
|
||||
*/
|
||||
const statusPollingIntervals = new Map<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
|
||||
*/
|
||||
@@ -790,6 +811,7 @@ export function registerMRReviewHandlers(
|
||||
}
|
||||
|
||||
const reviewedCommitSha = review.reviewedCommitSha || (review as any).reviewed_commit_sha;
|
||||
const reviewedAt = review.reviewedAt || (review as any).reviewed_at;
|
||||
if (!reviewedCommitSha) {
|
||||
debugLog('No reviewedCommitSha in review', { mrIid });
|
||||
return { hasNewCommits: false };
|
||||
@@ -813,27 +835,40 @@ export function registerMRReviewHandlers(
|
||||
if (reviewedCommitSha === currentHeadSha) {
|
||||
return {
|
||||
hasNewCommits: false,
|
||||
hasCommitsAfterPosting: false,
|
||||
currentSha: currentHeadSha,
|
||||
reviewedSha: reviewedCommitSha,
|
||||
};
|
||||
}
|
||||
|
||||
// Get commits to count new ones
|
||||
// Get commits to count new ones and check for commits after review posting
|
||||
const commits = await gitlabFetch(
|
||||
config.token,
|
||||
config.instanceUrl,
|
||||
`/projects/${encodedProject}/merge_requests/${mrIid}/commits`
|
||||
) as Array<{ id: string }>;
|
||||
) as Array<{ id: string; created_at: string }>;
|
||||
|
||||
// Find how many commits are after the reviewed one
|
||||
let newCommitCount = 0;
|
||||
// Check if any commits were added AFTER the review was posted
|
||||
let hasCommitsAfterPosting = false;
|
||||
const reviewTime = reviewedAt ? new Date(reviewedAt).getTime() : 0;
|
||||
|
||||
for (const commit of commits) {
|
||||
if (commit.id === reviewedCommitSha) break;
|
||||
newCommitCount++;
|
||||
// If commit was created after the review was posted, it's a true follow-up commit
|
||||
if (reviewTime > 0 && commit.created_at) {
|
||||
const commitTime = new Date(commit.created_at).getTime();
|
||||
if (commitTime > reviewTime) {
|
||||
hasCommitsAfterPosting = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
hasNewCommits: true,
|
||||
hasCommitsAfterPosting,
|
||||
currentSha: currentHeadSha,
|
||||
reviewedSha: reviewedCommitSha,
|
||||
newCommitCount: newCommitCount || 1,
|
||||
@@ -973,4 +1008,431 @@ export function registerMRReviewHandlers(
|
||||
);
|
||||
|
||||
debugLog('MR review handlers registered');
|
||||
|
||||
// ============================================
|
||||
// NEW: Additional handlers for feature parity
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Delete a posted MR review (note)
|
||||
*/
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.GITLAB_MR_DELETE_REVIEW,
|
||||
async (_event, projectId: string, mrIid: number, noteId: number): Promise<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,8 +12,12 @@ import { getOllamaExecutablePaths, getOllamaInstallCommand as getPlatformOllamaI
|
||||
import { IPC_CHANNELS } from '../../shared/constants';
|
||||
import type {
|
||||
IPCResult,
|
||||
InfrastructureStatus,
|
||||
MemoryValidationResult,
|
||||
} from '../../shared/types';
|
||||
import { openTerminalWithCommand } from './claude-code-handlers';
|
||||
import { getEmbeddingProvider } from './context/memory-service-factory';
|
||||
const { join } = path;
|
||||
|
||||
/**
|
||||
* Ollama Service Status
|
||||
@@ -547,6 +551,154 @@ export function registerMemoryHandlers(): void {
|
||||
}
|
||||
);
|
||||
|
||||
// ============================================
|
||||
// Memory Infrastructure Handlers (LadybugDB - libSQL)
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Get memory infrastructure status.
|
||||
* Checks if the local libSQL database is available and returns status.
|
||||
*
|
||||
* @async
|
||||
* @param {string} [dbPath] - Optional custom database path (for testing)
|
||||
* @returns {Promise<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,6 +266,9 @@ 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,6 +12,7 @@ import { debugLog, } from '../../shared/utils/debug-logger';
|
||||
import { migrateSession } from '../claude-profile/session-utils';
|
||||
import { createProfileDirectory } from '../claude-profile/profile-utils';
|
||||
import { isValidConfigDir } from '../utils/config-path-validator';
|
||||
import { sessionPersistence } from '../terminal/session-persistence';
|
||||
|
||||
|
||||
/**
|
||||
@@ -115,6 +116,22 @@ export function registerTerminalHandlers(
|
||||
}
|
||||
);
|
||||
|
||||
// Save terminal buffer (serialized xterm.js content)
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.TERMINAL_SAVE_BUFFER,
|
||||
async (_, terminalId: string, serializedBuffer: string): Promise<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,8 +14,23 @@ import type {
|
||||
TerminalRecoveryInfo,
|
||||
} from '../../shared/types/terminal-session';
|
||||
|
||||
const SESSIONS_FILE = path.join(app.getPath('userData'), 'terminal-sessions.json');
|
||||
const BUFFERS_DIR = path.join(app.getPath('userData'), 'terminal-buffers');
|
||||
// Helper to get app data path safely (works in tests where app might not be fully mocked)
|
||||
function getUserDataPath(): string {
|
||||
try {
|
||||
if (app && typeof app.getPath === 'function') {
|
||||
return app.getPath('userData');
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors in test environment
|
||||
}
|
||||
// Fallback for test environment - use home directory instead of /tmp for security
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
return path.join(os.homedir(), '.aperant-test-data');
|
||||
}
|
||||
|
||||
const SESSIONS_FILE = path.join(getUserDataPath(), 'terminal-sessions.json');
|
||||
const BUFFERS_DIR = path.join(getUserDataPath(), 'terminal-buffers');
|
||||
|
||||
// Session age limit: 7 days
|
||||
const MAX_SESSION_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
@@ -307,19 +322,31 @@ class SessionPersistence {
|
||||
// Singleton instance
|
||||
export const sessionPersistence = new SessionPersistence();
|
||||
|
||||
// Hook into app lifecycle
|
||||
app.on('before-quit', () => {
|
||||
console.warn('[SessionPersistence] App quitting, saving sessions...');
|
||||
sessionPersistence.saveNow();
|
||||
});
|
||||
// Hook into app lifecycle (only if running in real Electron environment, not tests)
|
||||
try {
|
||||
if (app && typeof app.on === 'function') {
|
||||
app.on('before-quit', () => {
|
||||
console.warn('[SessionPersistence] App quitting, saving sessions...');
|
||||
sessionPersistence.saveNow();
|
||||
});
|
||||
|
||||
app.on('will-quit', () => {
|
||||
sessionPersistence.saveNow();
|
||||
});
|
||||
app.on('will-quit', () => {
|
||||
sessionPersistence.saveNow();
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors in test environment
|
||||
}
|
||||
|
||||
// Cleanup orphaned buffers on startup (after initial load)
|
||||
app.whenReady().then(() => {
|
||||
setTimeout(() => {
|
||||
sessionPersistence.cleanupOrphanedBuffers();
|
||||
}, 5000); // Wait 5 seconds after app ready
|
||||
});
|
||||
try {
|
||||
if (app && typeof app.whenReady === 'function') {
|
||||
app.whenReady().then(() => {
|
||||
setTimeout(() => {
|
||||
sessionPersistence.cleanupOrphanedBuffers();
|
||||
}, 5000); // Wait 5 seconds after app ready
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors in test environment
|
||||
}
|
||||
|
||||
@@ -178,10 +178,28 @@ 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 }>>;
|
||||
startGitHubAuth: () => Promise<IPCResult<{
|
||||
success: boolean;
|
||||
message?: string;
|
||||
deviceCode?: string;
|
||||
authUrl?: string;
|
||||
browserOpened?: boolean;
|
||||
fallbackUrl?: 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 }> }>>;
|
||||
@@ -571,6 +589,22 @@ 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),
|
||||
@@ -578,7 +612,14 @@ export const createGitHubAPI = (): GitHubAPI => ({
|
||||
checkGitHubAuth: (): Promise<IPCResult<{ authenticated: boolean; username?: string }>> =>
|
||||
invokeIpc(IPC_CHANNELS.GITHUB_CHECK_AUTH),
|
||||
|
||||
startGitHubAuth: (): Promise<IPCResult<{ success: boolean; message?: string }>> =>
|
||||
startGitHubAuth: (): Promise<IPCResult<{
|
||||
success: boolean;
|
||||
message?: string;
|
||||
deviceCode?: string;
|
||||
authUrl?: string;
|
||||
browserOpened?: boolean;
|
||||
fallbackUrl?: string;
|
||||
}>> =>
|
||||
invokeIpc(IPC_CHANNELS.GITHUB_START_AUTH),
|
||||
|
||||
getGitHubToken: (): Promise<IPCResult<{ token: string }>> =>
|
||||
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
GitLabAnalyzePreviewResult,
|
||||
GitLabTriageConfig,
|
||||
GitLabTriageResult,
|
||||
GitLabMRStatusUpdate,
|
||||
GitLabGroup,
|
||||
IPCResult
|
||||
} from '../../../shared/types';
|
||||
@@ -79,6 +80,30 @@ export interface GitLabAPI {
|
||||
cancelGitLabMRReview: (projectId: string, mrIid: number) => Promise<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
|
||||
@@ -89,6 +114,9 @@ export interface GitLabAPI {
|
||||
onGitLabMRReviewError: (
|
||||
callback: (projectId: string, data: { mrIid: number; error: string }) => void
|
||||
) => IpcListenerCleanup;
|
||||
onGitLabMRStatusUpdate: (
|
||||
callback: (update: GitLabMRStatusUpdate) => void
|
||||
) => IpcListenerCleanup;
|
||||
|
||||
// GitLab Auto-Fix operations
|
||||
getGitLabAutoFixConfig: (projectId: string) => Promise<GitLabAutoFixConfig | null>;
|
||||
@@ -109,7 +137,7 @@ export interface GitLabAPI {
|
||||
callback: (projectId: string, result: GitLabAutoFixQueueItem) => void
|
||||
) => IpcListenerCleanup;
|
||||
onGitLabAutoFixError: (
|
||||
callback: (projectId: string, error: string) => void
|
||||
callback: (projectId: string, issueIid: number, error: string) => void
|
||||
) => IpcListenerCleanup;
|
||||
onGitLabAutoFixAnalyzePreviewProgress: (
|
||||
callback: (projectId: string, progress: { phase: string; progress: number; message: string }) => void
|
||||
@@ -278,6 +306,56 @@ export const createGitLabAPI = (): GitLabAPI => ({
|
||||
checkGitLabMRNewCommits: (projectId: string, mrIid: number): Promise<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
|
||||
@@ -294,6 +372,11 @@ export const createGitLabAPI = (): GitLabAPI => ({
|
||||
): IpcListenerCleanup =>
|
||||
createIpcListener(IPC_CHANNELS.GITLAB_MR_REVIEW_ERROR, callback),
|
||||
|
||||
onGitLabMRStatusUpdate: (
|
||||
callback: (update: GitLabMRStatusUpdate) => void
|
||||
): IpcListenerCleanup =>
|
||||
createIpcListener(IPC_CHANNELS.GITLAB_MR_STATUS_UPDATE, callback),
|
||||
|
||||
// GitLab Auto-Fix operations
|
||||
getGitLabAutoFixConfig: (projectId: string): Promise<GitLabAutoFixConfig | null> =>
|
||||
invokeIpc(IPC_CHANNELS.GITLAB_AUTOFIX_GET_CONFIG, projectId),
|
||||
@@ -334,7 +417,7 @@ export const createGitLabAPI = (): GitLabAPI => ({
|
||||
createIpcListener(IPC_CHANNELS.GITLAB_AUTOFIX_COMPLETE, callback),
|
||||
|
||||
onGitLabAutoFixError: (
|
||||
callback: (projectId: string, error: string) => void
|
||||
callback: (projectId: string, issueIid: number, error: string) => void
|
||||
): IpcListenerCleanup =>
|
||||
createIpcListener(IPC_CHANNELS.GITLAB_AUTOFIX_ERROR, callback),
|
||||
|
||||
|
||||
@@ -11,5 +11,6 @@ export * from './insights-api';
|
||||
export * from './changelog-api';
|
||||
export * from './linear-api';
|
||||
export * from './github-api';
|
||||
export * from './gitlab-api';
|
||||
export * from './shell-api';
|
||||
export * from './debug-api';
|
||||
|
||||
@@ -9,7 +9,9 @@ import type {
|
||||
ProjectEnvConfig,
|
||||
GitStatus,
|
||||
KanbanPreferences,
|
||||
GitBranchDetail
|
||||
GitBranchDetail,
|
||||
InfrastructureStatus,
|
||||
MemoryValidationResult
|
||||
} from '../../shared/types';
|
||||
|
||||
// Tab state interface (persisted in main process)
|
||||
@@ -46,6 +48,11 @@ export interface ProjectAPI {
|
||||
searchMemories: (projectId: string, query: string) => Promise<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>>;
|
||||
@@ -284,5 +291,15 @@ export const createProjectAPI = (): ProjectAPI => ({
|
||||
ipcRenderer.invoke(IPC_CHANNELS.OLLAMA_LIST_EMBEDDING_MODELS, baseUrl),
|
||||
|
||||
pullOllamaModel: (modelName: string, baseUrl?: string) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.OLLAMA_PULL_MODEL, modelName, baseUrl)
|
||||
ipcRenderer.invoke(IPC_CHANNELS.OLLAMA_PULL_MODEL, modelName, baseUrl),
|
||||
|
||||
// Memory Infrastructure operations (LadybugDB - no Docker required)
|
||||
getMemoryInfrastructureStatus: (dbPath?: string) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.INFRASTRUCTURE_GET_STATUS, dbPath),
|
||||
|
||||
listMemoryDatabases: (dbPath?: string) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.INFRASTRUCTURE_LIST_DATABASES, dbPath),
|
||||
|
||||
testMemoryConnection: (dbPath?: string, database?: string) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.INFRASTRUCTURE_TEST_CONNECTION, dbPath, database)
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface SettingsAPI {
|
||||
python: ToolDetectionResult;
|
||||
git: ToolDetectionResult;
|
||||
gh: ToolDetectionResult;
|
||||
glab: ToolDetectionResult;
|
||||
claude: ToolDetectionResult;
|
||||
}>>;
|
||||
|
||||
@@ -64,6 +65,7 @@ export const createSettingsAPI = (): SettingsAPI => ({
|
||||
python: ToolDetectionResult;
|
||||
git: ToolDetectionResult;
|
||||
gh: ToolDetectionResult;
|
||||
glab: ToolDetectionResult;
|
||||
claude: ToolDetectionResult;
|
||||
}>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.SETTINGS_GET_CLI_TOOLS_INFO),
|
||||
|
||||
@@ -72,6 +72,9 @@ 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;
|
||||
@@ -210,6 +213,10 @@ 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,7 +23,6 @@ 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)} />;
|
||||
}
|
||||
@@ -63,7 +62,6 @@ function ReviewStatusBadge({ status, className }: ReviewStatusBadgeProps) {
|
||||
{t('prStatus.review.pending')}
|
||||
</Badge>
|
||||
);
|
||||
case 'none':
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -88,7 +86,6 @@ 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)} />;
|
||||
}
|
||||
@@ -169,9 +166,9 @@ export function StatusIndicator({
|
||||
)}
|
||||
|
||||
{/* Merge Readiness */}
|
||||
{showMergeStatus && mergeKey && (
|
||||
{showMergeStatus && mergeKey && mergeableState && (
|
||||
<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}`)}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* AutoFixButton Component for GitLab Issues
|
||||
*
|
||||
* Stub component - implements the same pattern as GitHub's AutoFixButton
|
||||
* adapted for GitLab issues.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Wand2, Loader2, AlertCircle, CheckCircle2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import type { GitLabIssue, GitLabAutoFixConfig, GitLabAutoFixProgress, GitLabAutoFixQueueItem } from '@shared/types';
|
||||
|
||||
interface GitLabAutoFixButtonProps {
|
||||
issue: GitLabIssue;
|
||||
projectId: string;
|
||||
config: GitLabAutoFixConfig | null;
|
||||
queueItem: GitLabAutoFixQueueItem | null;
|
||||
onStartAutoFix?: (projectId: string, issueIid: number) => void;
|
||||
}
|
||||
|
||||
export function GitLabAutoFixButton({
|
||||
issue,
|
||||
projectId,
|
||||
config,
|
||||
queueItem,
|
||||
onStartAutoFix,
|
||||
}: GitLabAutoFixButtonProps) {
|
||||
const { t } = useTranslation(['gitlab']);
|
||||
const [isStarting, setIsStarting] = useState(false);
|
||||
const [progress, setProgress] = useState<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,553 @@
|
||||
/**
|
||||
* BatchReviewWizard Component for GitLab Issues
|
||||
*
|
||||
* Stub component - implements the same pattern as GitHub's BatchReviewWizard
|
||||
* adapted for GitLab issues (using iid instead of issueNumber).
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Layers,
|
||||
CheckCircle2,
|
||||
Loader2,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Users,
|
||||
Play,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible';
|
||||
import type {
|
||||
GitLabAnalyzePreviewResult,
|
||||
GitLabAnalyzePreviewProgress,
|
||||
GitLabProposedBatch,
|
||||
} from '@shared/types';
|
||||
|
||||
interface GitLabBatchReviewWizardProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onStartAnalysis: () => void;
|
||||
onApproveBatches: (batches: GitLabProposedBatch[]) => Promise<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,3 +4,5 @@ export { InvestigationDialog } from './InvestigationDialog';
|
||||
export { EmptyState, NotConnectedState } from './EmptyStates';
|
||||
export { IssueListHeader } from './IssueListHeader';
|
||||
export { IssueList } from './IssueList';
|
||||
export { GitLabAutoFixButton } from './AutoFixButton';
|
||||
export { GitLabBatchReviewWizard } from './BatchReviewWizard';
|
||||
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Unit tests for GitLab error parser
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
parseGitLabError,
|
||||
isRecoverableGitLabError,
|
||||
GitLabErrorCode
|
||||
} from '../gitlab-error-parser';
|
||||
|
||||
describe('gitlab-error-parser', () => {
|
||||
it('should parse 401 authentication errors', () => {
|
||||
const error = new Error('401 Unauthorized');
|
||||
const parsed = parseGitLabError(error);
|
||||
|
||||
expect(parsed.code).toBe(GitLabErrorCode.AUTHENTICATION_FAILED);
|
||||
expect(parsed.recoverable).toBe(true);
|
||||
});
|
||||
|
||||
it('should parse 403 permission errors', () => {
|
||||
const error = new Error('403 Forbidden');
|
||||
const parsed = parseGitLabError(error);
|
||||
|
||||
expect(parsed.code).toBe(GitLabErrorCode.INSUFFICIENT_PERMISSIONS);
|
||||
expect(parsed.recoverable).toBe(false);
|
||||
});
|
||||
|
||||
it('should parse 404 not found errors', () => {
|
||||
const error = new Error('404 Not Found');
|
||||
const parsed = parseGitLabError(error);
|
||||
|
||||
expect(parsed.code).toBe(GitLabErrorCode.PROJECT_NOT_FOUND);
|
||||
expect(parsed.recoverable).toBe(false);
|
||||
});
|
||||
|
||||
it('should parse 409 conflict errors', () => {
|
||||
const error = new Error('409 Conflict');
|
||||
const parsed = parseGitLabError(error);
|
||||
|
||||
expect(parsed.code).toBe(GitLabErrorCode.CONFLICT);
|
||||
expect(parsed.recoverable).toBe(false);
|
||||
});
|
||||
|
||||
it('should parse 429 rate limit errors', () => {
|
||||
const error = 'Rate limit exceeded';
|
||||
const parsed = parseGitLabError(error);
|
||||
|
||||
expect(parsed.code).toBe(GitLabErrorCode.RATE_LIMITED);
|
||||
expect(parsed.recoverable).toBe(true);
|
||||
});
|
||||
|
||||
it('should identify recoverable errors', () => {
|
||||
expect(isRecoverableGitLabError(new Error('401 Unauthorized'))).toBe(true);
|
||||
expect(isRecoverableGitLabError(new Error('Network error'))).toBe(true);
|
||||
expect(isRecoverableGitLabError(new Error('409 Conflict'))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* GitLab Error Parser Utility
|
||||
*
|
||||
* Parses GitLab API errors and returns error codes for i18n translation.
|
||||
* Follows the same pattern as GitHub's error parser.
|
||||
*/
|
||||
|
||||
export enum GitLabErrorCode {
|
||||
AUTHENTICATION_FAILED = 'AUTHENTICATION_FAILED',
|
||||
RATE_LIMITED = 'RATE_LIMITED',
|
||||
NETWORK_ERROR = 'NETWORK_ERROR',
|
||||
PROJECT_NOT_FOUND = 'PROJECT_NOT_FOUND',
|
||||
INSUFFICIENT_PERMISSIONS = 'INSUFFICIENT_PERMISSIONS',
|
||||
CONFLICT = 'CONFLICT',
|
||||
UNKNOWN = 'UNKNOWN'
|
||||
}
|
||||
|
||||
export interface ParsedGitLabError {
|
||||
code: GitLabErrorCode;
|
||||
recoverable: boolean;
|
||||
details?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a GitLab error and return an error code
|
||||
*/
|
||||
export function parseGitLabError(error: unknown): ParsedGitLabError {
|
||||
if (error instanceof Error) {
|
||||
return parseGitLabErrorMessage(error.message);
|
||||
}
|
||||
|
||||
if (typeof error === 'string') {
|
||||
return parseGitLabErrorMessage(error);
|
||||
}
|
||||
|
||||
// Handle Error-like objects with a message property
|
||||
if (typeof error === 'object' && error !== null && 'message' in error) {
|
||||
const message = (error as { message: string }).message;
|
||||
if (typeof message === 'string') {
|
||||
return parseGitLabErrorMessage(message);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
code: GitLabErrorCode.UNKNOWN,
|
||||
recoverable: false
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse GitLab error message
|
||||
*/
|
||||
function parseGitLabErrorMessage(message: string): ParsedGitLabError {
|
||||
const lowerMessage = message.toLowerCase();
|
||||
|
||||
// Check for explicit HTTP status code in response (if available)
|
||||
// Try to extract status from common patterns like "Status: 401" or HTTP error responses
|
||||
const statusMatch = message.match(/\bstatus:\s*(\d{3})\b/i) ||
|
||||
message.match(/\bhttp\s+(\d{3})\b/i) ||
|
||||
lowerMessage.match(/\b"status":\s*(\d{3})\b/);
|
||||
|
||||
if (statusMatch) {
|
||||
const statusCode = parseInt(statusMatch[1], 10);
|
||||
switch (statusCode) {
|
||||
case 401:
|
||||
return { code: GitLabErrorCode.AUTHENTICATION_FAILED, recoverable: true };
|
||||
case 403:
|
||||
return { code: GitLabErrorCode.INSUFFICIENT_PERMISSIONS, recoverable: false };
|
||||
case 404:
|
||||
return { code: GitLabErrorCode.PROJECT_NOT_FOUND, recoverable: false };
|
||||
case 409:
|
||||
return { code: GitLabErrorCode.CONFLICT, recoverable: false };
|
||||
case 429:
|
||||
return { code: GitLabErrorCode.RATE_LIMITED, recoverable: true };
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to message content analysis with word-boundary regex to avoid false matches
|
||||
// Authentication errors
|
||||
if (/\b401\b/.test(message) || lowerMessage.includes('unauthorized') || lowerMessage.includes('invalid token')) {
|
||||
return {
|
||||
code: GitLabErrorCode.AUTHENTICATION_FAILED,
|
||||
recoverable: true
|
||||
};
|
||||
}
|
||||
|
||||
// Rate limiting (429)
|
||||
if (/\b429\b/.test(message) || lowerMessage.includes('rate limit') || lowerMessage.includes('too many requests')) {
|
||||
return {
|
||||
code: GitLabErrorCode.RATE_LIMITED,
|
||||
recoverable: true
|
||||
};
|
||||
}
|
||||
|
||||
// Network errors - use specific phrases to avoid false positives
|
||||
if (lowerMessage.includes('network') ||
|
||||
lowerMessage.includes('connection refused') ||
|
||||
lowerMessage.includes('connection failed') ||
|
||||
lowerMessage.includes('unable to connect') ||
|
||||
lowerMessage.includes('connect timeout') ||
|
||||
lowerMessage.includes('timeout')) {
|
||||
return {
|
||||
code: GitLabErrorCode.NETWORK_ERROR,
|
||||
recoverable: true
|
||||
};
|
||||
}
|
||||
|
||||
// Project not found (404) - not recoverable
|
||||
if (/\b404\b/.test(message) || lowerMessage.includes('not found')) {
|
||||
return {
|
||||
code: GitLabErrorCode.PROJECT_NOT_FOUND,
|
||||
recoverable: false
|
||||
};
|
||||
}
|
||||
|
||||
// Permission denied (403) - not recoverable
|
||||
if (/\b403\b/.test(message) || lowerMessage.includes('forbidden') || lowerMessage.includes('permission denied')) {
|
||||
return {
|
||||
code: GitLabErrorCode.INSUFFICIENT_PERMISSIONS,
|
||||
recoverable: false
|
||||
};
|
||||
}
|
||||
|
||||
// Conflict (409)
|
||||
if (/\b409\b/.test(message) || lowerMessage.includes('conflict')) {
|
||||
return {
|
||||
code: GitLabErrorCode.CONFLICT,
|
||||
recoverable: false
|
||||
};
|
||||
}
|
||||
|
||||
// Default error
|
||||
return {
|
||||
code: GitLabErrorCode.UNKNOWN,
|
||||
recoverable: false,
|
||||
details: message
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error is recoverable
|
||||
*/
|
||||
export function isRecoverableGitLabError(error: unknown): boolean {
|
||||
const parsed = parseGitLabError(error);
|
||||
return parsed.recoverable;
|
||||
}
|
||||
+568
@@ -0,0 +1,568 @@
|
||||
/**
|
||||
* Filter bar for GitLab MRs list
|
||||
* Grid layout: Contributors (3) | Status (3) | Search (8)
|
||||
* Multi-select dropdowns with visible chip selections
|
||||
*
|
||||
* Stub component - implements the same pattern as PRFilterBar
|
||||
* adapted for GitLab merge requests.
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useRef, useCallback, useEffect } from 'react';
|
||||
import {
|
||||
Search,
|
||||
Users,
|
||||
Sparkles,
|
||||
CheckCircle2,
|
||||
Send,
|
||||
AlertCircle,
|
||||
CheckCheck,
|
||||
RefreshCw,
|
||||
X,
|
||||
Filter,
|
||||
Check,
|
||||
Loader2,
|
||||
ArrowUpDown,
|
||||
Clock,
|
||||
FileCode
|
||||
} from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { GitLabMRFilterState, GitLabMRStatusFilter, GitLabMRSortOption } from '../hooks/useGitLabMRFiltering';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface MRFilterBarProps {
|
||||
filters: GitLabMRFilterState;
|
||||
contributors: string[];
|
||||
hasActiveFilters: boolean;
|
||||
onSearchChange: (query: string) => void;
|
||||
onContributorsChange: (contributors: string[]) => void;
|
||||
onStatusesChange: (statuses: GitLabMRStatusFilter[]) => void;
|
||||
onSortChange: (sortBy: GitLabMRSortOption) => void;
|
||||
onClearFilters: () => void;
|
||||
}
|
||||
|
||||
// Status options
|
||||
const STATUS_OPTIONS: Array<{
|
||||
value: GitLabMRStatusFilter;
|
||||
labelKey: string;
|
||||
icon: typeof Sparkles;
|
||||
color: string;
|
||||
bgColor: string;
|
||||
}> = [
|
||||
{ value: 'reviewing', labelKey: 'mrFiltering.reviewing', icon: Loader2, color: 'text-amber-400', bgColor: 'bg-amber-500/20' },
|
||||
{ value: 'not_reviewed', labelKey: 'mrFiltering.notReviewed', icon: Sparkles, color: 'text-slate-500', bgColor: 'bg-slate-500/20' },
|
||||
{ value: 'reviewed', labelKey: 'mrFiltering.reviewed', icon: CheckCircle2, color: 'text-blue-400', bgColor: 'bg-blue-500/20' },
|
||||
{ value: 'posted', labelKey: 'mrFiltering.posted', icon: Send, color: 'text-purple-400', bgColor: 'bg-purple-500/20' },
|
||||
{ value: 'changes_requested', labelKey: 'mrFiltering.changesRequested', icon: AlertCircle, color: 'text-red-400', bgColor: 'bg-red-500/20' },
|
||||
{ value: 'ready_to_merge', labelKey: 'mrFiltering.readyToMerge', icon: CheckCheck, color: 'text-emerald-400', bgColor: 'bg-emerald-500/20' },
|
||||
{ value: 'ready_for_followup', labelKey: 'mrFiltering.readyForFollowup', icon: RefreshCw, color: 'text-cyan-400', bgColor: 'bg-cyan-500/20' },
|
||||
];
|
||||
|
||||
// Sort options
|
||||
const SORT_OPTIONS: Array<{
|
||||
value: GitLabMRSortOption;
|
||||
labelKey: string;
|
||||
icon: typeof Clock;
|
||||
}> = [
|
||||
{ value: 'newest', labelKey: 'mrFiltering.sort.newest', icon: Clock },
|
||||
{ value: 'oldest', labelKey: 'mrFiltering.sort.oldest', icon: Clock },
|
||||
{ value: 'largest', labelKey: 'mrFiltering.sort.largest', icon: FileCode },
|
||||
];
|
||||
|
||||
/**
|
||||
* Modern Filter Dropdown Component
|
||||
*/
|
||||
function FilterDropdown<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,732 @@
|
||||
/**
|
||||
* MR Logs Component
|
||||
*
|
||||
* Displays detailed logs from GitLab merge request review operations.
|
||||
* Shows AI analysis phases, agent activities, and review progress.
|
||||
*
|
||||
* Stub component - implements the same pattern as PRLogs
|
||||
* adapted for GitLab merge requests.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Terminal,
|
||||
Loader2,
|
||||
FolderOpen,
|
||||
BrainCircuit,
|
||||
FileCheck,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Info,
|
||||
Clock,
|
||||
Activity
|
||||
} from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type {
|
||||
PRLogs,
|
||||
PRLogPhase,
|
||||
PRPhaseLog,
|
||||
PRLogEntry
|
||||
} from '@preload/api/modules/github-api';
|
||||
|
||||
// Type aliases for GitLab compatibility
|
||||
type GitLabMRLogs = PRLogs;
|
||||
type GitLabMRLogPhase = PRLogPhase;
|
||||
type GitLabMRPhaseLog = PRPhaseLog;
|
||||
type GitLabMRLogEntry = PRLogEntry;
|
||||
|
||||
interface MRLogsProps {
|
||||
mrIid: number;
|
||||
logs: GitLabMRLogs | null;
|
||||
isLoading: boolean;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
// TODO: The GITLAB_MR_GET_LOGS IPC handler returns string[] but this component expects PRLogs.
|
||||
// Add a transformation to convert string[] to PRLogs structure in the handler or a data layer.
|
||||
// For now, handle both formats defensively.
|
||||
|
||||
// Phase label translation key mapping
|
||||
const PHASE_LABEL_KEYS: Record<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>
|
||||
);
|
||||
}
|
||||
+246
@@ -0,0 +1,246 @@
|
||||
import { CheckCircle2, Circle, XCircle, Loader2, AlertTriangle, GitMerge, HelpCircle } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ChecksStatus, ReviewsStatus, MergeableState } from '@shared/types/pr-status';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* CI Status Icon Component
|
||||
* Displays an icon representing the CI checks status
|
||||
*/
|
||||
interface CIStatusIconProps {
|
||||
status: ChecksStatus;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function CIStatusIcon({ status, className }: CIStatusIconProps) {
|
||||
const baseClasses = 'h-4 w-4';
|
||||
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return <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,3 +6,6 @@ export { ReviewFindings } from './ReviewFindings';
|
||||
export { FindingItem } from './FindingItem';
|
||||
export { FindingsSummary } from './FindingsSummary';
|
||||
export { SeverityGroupHeader } from './SeverityGroupHeader';
|
||||
export { MRFilterBar } from './MRFilterBar';
|
||||
export { MRStatusIndicator, CompactMRStatusIndicator } from './StatusIndicator';
|
||||
export { MRLogs } from './MRLogs';
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './useGitLabMRs';
|
||||
export * from './useFindingSelection';
|
||||
export * from './useGitLabMRFiltering';
|
||||
|
||||
+251
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* Hook for filtering and searching GitLab MRs
|
||||
*
|
||||
* Stub hook - implements the same pattern as usePRFiltering
|
||||
* adapted for GitLab merge requests.
|
||||
*
|
||||
* NOTE: This hook and MRFilterBar are reserved for future filtering functionality.
|
||||
* They are not currently integrated into the GitLab MRs UI but are retained
|
||||
* for when filtering/search features are implemented.
|
||||
*
|
||||
* TODO: Integrate MRFilterBar into GitLabMergeRequests.tsx by:
|
||||
* 1. Importing useGitLabMRFiltering hook
|
||||
* 2. Importing MRFilterBar component from ./components/MRFilterBar
|
||||
* 3. Adding filter state management to GitLabMergeRequests component
|
||||
* 4. Passing filtered MRs to MergeRequestList instead of raw mrs
|
||||
* 5. Adding UI toggle for filter bar visibility
|
||||
*/
|
||||
|
||||
import { useMemo, useState, useCallback } from 'react';
|
||||
import type {
|
||||
GitLabMergeRequest,
|
||||
GitLabMRReviewResult,
|
||||
GitLabMRReviewProgress,
|
||||
GitLabNewCommitsCheck
|
||||
} from '@shared/types';
|
||||
|
||||
export type GitLabMRStatusFilter =
|
||||
| 'all'
|
||||
| 'reviewing'
|
||||
| 'not_reviewed'
|
||||
| 'reviewed'
|
||||
| 'posted'
|
||||
| 'changes_requested'
|
||||
| 'ready_to_merge'
|
||||
| 'ready_for_followup';
|
||||
|
||||
export type GitLabMRSortOption = 'newest' | 'oldest' | 'largest';
|
||||
|
||||
export interface GitLabMRFilterState {
|
||||
searchQuery: string;
|
||||
contributors: string[];
|
||||
statuses: GitLabMRStatusFilter[];
|
||||
sortBy: GitLabMRSortOption;
|
||||
}
|
||||
|
||||
interface GitLabMRReviewInfo {
|
||||
isReviewing: boolean;
|
||||
result: GitLabMRReviewResult | null;
|
||||
newCommitsCheck?: GitLabNewCommitsCheck | null;
|
||||
}
|
||||
|
||||
const DEFAULT_FILTERS: GitLabMRFilterState = {
|
||||
searchQuery: '',
|
||||
contributors: [],
|
||||
statuses: [],
|
||||
sortBy: 'newest',
|
||||
};
|
||||
|
||||
/**
|
||||
* Determine the computed status of an MR based on its review state
|
||||
*/
|
||||
function getMRComputedStatus(
|
||||
reviewInfo: GitLabMRReviewInfo | null
|
||||
): GitLabMRStatusFilter {
|
||||
// Check if currently reviewing (highest priority)
|
||||
if (reviewInfo?.isReviewing) {
|
||||
return 'reviewing';
|
||||
}
|
||||
|
||||
if (!reviewInfo?.result) {
|
||||
return 'not_reviewed';
|
||||
}
|
||||
|
||||
const result = reviewInfo.result;
|
||||
const hasPosted = Boolean(result.hasPostedFindings);
|
||||
// Use overallStatus from review result as source of truth, fallback to severity check
|
||||
const hasBlockingFindings =
|
||||
result.overallStatus === 'request_changes' ||
|
||||
result.findings?.some(f => f.severity === 'critical' || f.severity === 'high');
|
||||
// Use backend-calculated field that compares commit timestamps against review post time
|
||||
const hasCommitsAfterPosting = reviewInfo.newCommitsCheck?.hasCommitsAfterPosting ?? false;
|
||||
|
||||
// Check for ready for follow-up first (highest priority after posting)
|
||||
// Must have new commits that happened AFTER findings were posted
|
||||
if (hasCommitsAfterPosting) {
|
||||
return 'ready_for_followup';
|
||||
}
|
||||
|
||||
// Posted with blocking findings
|
||||
if (hasPosted && hasBlockingFindings) {
|
||||
return 'changes_requested';
|
||||
}
|
||||
|
||||
// Posted without blocking findings
|
||||
if (hasPosted) {
|
||||
return 'ready_to_merge';
|
||||
}
|
||||
|
||||
// Has review result but not posted yet
|
||||
return 'reviewed';
|
||||
}
|
||||
|
||||
export function useGitLabMRFiltering(
|
||||
mrs: GitLabMergeRequest[],
|
||||
getReviewStateForMR: (mrIid: number) => {
|
||||
isReviewing: boolean;
|
||||
progress: GitLabMRReviewProgress | null;
|
||||
result: GitLabMRReviewResult | null;
|
||||
error: string | null;
|
||||
newCommitsCheck: GitLabNewCommitsCheck | null;
|
||||
} | null
|
||||
) {
|
||||
const [filters, setFiltersState] = useState<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,6 +50,15 @@ 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 {
|
||||
@@ -62,7 +71,6 @@ 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);
|
||||
|
||||
@@ -279,6 +287,77 @@ export function useGitLabMRs(projectId?: string, options: UseGitLabMRsOptions =
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
// NEW: Additional methods for feature parity
|
||||
const listMoreMRs = useCallback(async (page: number = 2): Promise<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,
|
||||
@@ -303,5 +382,9 @@ export function useGitLabMRs(projectId?: string, options: UseGitLabMRsOptions =
|
||||
assignMR,
|
||||
approveMR,
|
||||
getReviewStateForMR,
|
||||
listMoreMRs,
|
||||
deleteReview,
|
||||
checkMergeReadiness,
|
||||
getLogs,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -156,7 +156,12 @@ export function GraphitiStep({ onNext, onBack, onSkip }: GraphitiStepProps) {
|
||||
setIsCheckingInfra(true);
|
||||
try {
|
||||
const result = await window.electronAPI.getMemoryInfrastructureStatus();
|
||||
setKuzuAvailable(!!(result?.success && result?.data?.memory?.kuzuInstalled ));
|
||||
if (result?.success && result?.data && typeof result.data === 'object') {
|
||||
const data = result.data as { memory?: { kuzuInstalled?: boolean } };
|
||||
setKuzuAvailable(!!data.memory?.kuzuInstalled);
|
||||
} else {
|
||||
setKuzuAvailable(false);
|
||||
}
|
||||
} catch {
|
||||
setKuzuAvailable(false);
|
||||
} finally {
|
||||
@@ -233,28 +238,18 @@ export function GraphitiStep({ onNext, onBack, onSkip }: GraphitiStepProps) {
|
||||
setValidationStatus({ database: null, provider: null });
|
||||
|
||||
try {
|
||||
// Get the API key for the current LLM provider
|
||||
const apiKey = config.llmProvider === 'openai' ? config.openaiApiKey :
|
||||
config.llmProvider === 'anthropic' ? config.anthropicApiKey :
|
||||
config.llmProvider === 'google' ? config.googleApiKey :
|
||||
config.llmProvider === 'groq' ? config.groqApiKey :
|
||||
config.llmProvider === 'openrouter' ? config.openrouterApiKey :
|
||||
config.llmProvider === 'azure_openai' ? config.azureOpenaiApiKey :
|
||||
config.llmProvider === 'ollama' ? '' : // Ollama doesn't need API key
|
||||
config.embeddingProvider === 'openai' ? config.openaiApiKey :
|
||||
config.embeddingProvider === 'openrouter' ? config.openrouterApiKey : '';
|
||||
|
||||
const result = await window.electronAPI.testMemoryConnection(
|
||||
config.dbPath || undefined,
|
||||
config.database || 'auto_claude_memory'
|
||||
);
|
||||
|
||||
if (result?.success && result?.data) {
|
||||
if (result?.success && result?.data && typeof result.data === 'object') {
|
||||
const data = result.data as { success?: boolean; message?: string };
|
||||
setValidationStatus({
|
||||
database: {
|
||||
tested: true,
|
||||
success: result.data.success,
|
||||
message: result.data.message
|
||||
success: data.success ?? false,
|
||||
message: data.message || 'Connection test completed'
|
||||
},
|
||||
provider: {
|
||||
tested: true,
|
||||
@@ -263,8 +258,8 @@ export function GraphitiStep({ onNext, onBack, onSkip }: GraphitiStepProps) {
|
||||
}
|
||||
});
|
||||
|
||||
if (!result.data.success) {
|
||||
setError(`Database: ${result.data.message}`);
|
||||
if (data.success === false) {
|
||||
setError(`Database: ${data.message || 'Connection failed'}`);
|
||||
}
|
||||
} else {
|
||||
setError(result?.error || 'Failed to test connection');
|
||||
|
||||
@@ -27,8 +27,12 @@ const isElectron = typeof window !== 'undefined' && window.electronAPI !== undef
|
||||
/**
|
||||
* Create mock electronAPI for browser
|
||||
* Aggregates all mock implementations from separate modules
|
||||
*
|
||||
* Note: This mock is used for UI development in a browser environment and doesn't
|
||||
* need to implement every single API method. The type assertion is necessary
|
||||
* because the mock is intentionally incomplete.
|
||||
*/
|
||||
const browserMockAPI: ElectronAPI = {
|
||||
const browserMockAPI = {
|
||||
// Project Operations
|
||||
...projectMock,
|
||||
|
||||
@@ -318,7 +322,11 @@ const browserMockAPI: ElectronAPI = {
|
||||
startStatusPolling: async () => true,
|
||||
stopStatusPolling: async () => true,
|
||||
getPollingMetadata: async () => null,
|
||||
onPRStatusUpdate: () => () => {}
|
||||
onPRStatusUpdate: () => () => {},
|
||||
// Release operations (changelog-based)
|
||||
getReleaseableVersions: async () => ({ success: true, data: [] }),
|
||||
runReleasePreflightCheck: async () => ({ success: true, data: { canRelease: true, checks: [], blockers: [] } }),
|
||||
createRelease: async () => ({ success: true, data: { url: '' } })
|
||||
},
|
||||
|
||||
// Queue Routing API (rate limit recovery)
|
||||
@@ -452,7 +460,7 @@ const browserMockAPI: ElectronAPI = {
|
||||
copyDebugInfo: async () => ({ success: false, error: 'Not available in browser mode' }),
|
||||
getRecentErrors: async () => [],
|
||||
listLogFiles: async () => []
|
||||
};
|
||||
} satisfies Partial<ElectronAPI>;
|
||||
|
||||
/**
|
||||
* Initialize browser mock if not running in Electron
|
||||
@@ -460,7 +468,10 @@ const browserMockAPI: ElectronAPI = {
|
||||
export function initBrowserMock(): void {
|
||||
if (!isElectron) {
|
||||
console.warn('%c[Browser Mock] Initializing mock electronAPI for browser preview', 'color: #f0ad4e; font-weight: bold;');
|
||||
(window as Window & { electronAPI: ElectronAPI }).electronAPI = browserMockAPI;
|
||||
// Type assertion: browser mock is used for UI development in browser
|
||||
// and doesn't need to implement every single API method
|
||||
// Cast through unknown since we're using Partial<ElectronAPI> for type safety
|
||||
(window as Window & { electronAPI: ElectronAPI }).electronAPI = browserMockAPI as unknown as ElectronAPI;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -134,8 +134,9 @@ export const changelogMock = {
|
||||
}
|
||||
}),
|
||||
|
||||
createRelease: () => {
|
||||
createRelease: async () => {
|
||||
console.warn('[Browser Mock] createRelease called');
|
||||
return { success: true, data: { url: '', tagName: '' } };
|
||||
},
|
||||
|
||||
onReleaseProgress: () => () => {},
|
||||
|
||||
@@ -125,5 +125,33 @@ export const projectMock = {
|
||||
initializeGit: async () => ({
|
||||
success: true,
|
||||
data: { success: true }
|
||||
}),
|
||||
|
||||
// Memory Infrastructure operations (LadybugDB)
|
||||
getMemoryInfrastructureStatus: async () => ({
|
||||
success: true,
|
||||
data: {
|
||||
memory: {
|
||||
kuzuInstalled: true,
|
||||
databasePath: '~/.auto-claude/graphs',
|
||||
databaseExists: true,
|
||||
databases: ['auto_claude_memory']
|
||||
},
|
||||
ready: true
|
||||
}
|
||||
}),
|
||||
|
||||
listMemoryDatabases: async () => ({
|
||||
success: true,
|
||||
data: ['auto_claude_memory', 'project_memory']
|
||||
}),
|
||||
|
||||
testMemoryConnection: async () => ({
|
||||
success: true,
|
||||
data: {
|
||||
success: true,
|
||||
message: 'Connected to LadybugDB database (mock)',
|
||||
details: { latencyMs: 5 }
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
@@ -82,7 +82,7 @@ export const terminalMock = {
|
||||
}
|
||||
}),
|
||||
|
||||
saveTerminalBuffer: async () => {},
|
||||
saveTerminalBuffer: async (_terminalId: string, _serializedBuffer: string) => ({ success: true }),
|
||||
|
||||
checkTerminalPtyAlive: async () => ({
|
||||
success: true,
|
||||
|
||||
@@ -107,11 +107,28 @@ export async function loadProjectContext(projectId: string): Promise<void> {
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.getProjectContext(projectId);
|
||||
if (result.success && result.data) {
|
||||
store.setProjectIndex(result.data.projectIndex);
|
||||
store.setMemoryStatus(result.data.memoryStatus);
|
||||
store.setMemoryState(result.data.memoryState);
|
||||
store.setRecentMemories(result.data.recentMemories || []);
|
||||
if (result.success && result.data && typeof result.data === 'object') {
|
||||
const data = result.data as {
|
||||
projectIndex?: unknown;
|
||||
memoryStatus?: unknown;
|
||||
memoryState?: unknown;
|
||||
recentMemories?: unknown;
|
||||
};
|
||||
if (data.projectIndex && typeof data.projectIndex === 'object') {
|
||||
store.setProjectIndex(data.projectIndex as ProjectIndex);
|
||||
}
|
||||
if (data.memoryStatus && typeof data.memoryStatus === 'object') {
|
||||
store.setMemoryStatus(data.memoryStatus as MemorySystemStatus);
|
||||
}
|
||||
if (data.memoryState && typeof data.memoryState === 'object') {
|
||||
store.setMemoryState(data.memoryState as MemorySystemState);
|
||||
}
|
||||
if (Array.isArray(data.recentMemories)) {
|
||||
store.setRecentMemories(data.recentMemories as RendererMemory[]);
|
||||
} else if (result.success) {
|
||||
// Unexpected data shape - clear to avoid stale data
|
||||
store.setRecentMemories([]);
|
||||
}
|
||||
} else {
|
||||
store.setIndexError(result.error || 'Failed to load project context');
|
||||
}
|
||||
@@ -133,8 +150,8 @@ export async function refreshProjectIndex(projectId: string): Promise<void> {
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.refreshProjectIndex(projectId);
|
||||
if (result.success && result.data) {
|
||||
store.setProjectIndex(result.data);
|
||||
if (result.success && result.data && typeof result.data === 'object') {
|
||||
store.setProjectIndex(result.data as ProjectIndex);
|
||||
} else {
|
||||
store.setIndexError(result.error || 'Failed to refresh project index');
|
||||
}
|
||||
@@ -164,8 +181,8 @@ export async function searchMemories(
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.searchMemories(projectId, query);
|
||||
if (result.success && result.data) {
|
||||
store.setSearchResults(result.data);
|
||||
if (result.success && result.data && Array.isArray(result.data)) {
|
||||
store.setSearchResults(result.data as ContextSearchResult[]);
|
||||
} else {
|
||||
store.setSearchResults([]);
|
||||
}
|
||||
@@ -188,8 +205,8 @@ export async function loadRecentMemories(
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.getRecentMemories(projectId, limit);
|
||||
if (result.success && result.data) {
|
||||
store.setRecentMemories(result.data);
|
||||
if (result.success && result.data && Array.isArray(result.data)) {
|
||||
store.setRecentMemories(result.data as RendererMemory[]);
|
||||
}
|
||||
} catch (_error) {
|
||||
// Silently fail - memories are optional
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Unit tests for GitLab investigation store
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { useInvestigationStore } from '../investigation-store';
|
||||
import type { GitLabInvestigationStatus, GitLabInvestigationResult } from '@shared/types';
|
||||
|
||||
describe('investigation-store', () => {
|
||||
beforeEach(() => {
|
||||
useInvestigationStore.getState().clearInvestigation();
|
||||
});
|
||||
|
||||
it('should initialize with idle state', () => {
|
||||
const state = useInvestigationStore.getState();
|
||||
expect(state.investigationStatus.phase).toBe('idle');
|
||||
expect(state.lastInvestigationResult).toBe(null);
|
||||
});
|
||||
|
||||
it('should set investigation status', () => {
|
||||
const status: GitLabInvestigationStatus = {
|
||||
phase: 'fetching',
|
||||
issueIid: 1,
|
||||
progress: 25,
|
||||
message: 'Fetching issue...'
|
||||
};
|
||||
useInvestigationStore.getState().setInvestigationStatus(status);
|
||||
expect(useInvestigationStore.getState().investigationStatus.phase).toBe('fetching');
|
||||
});
|
||||
|
||||
it('should set investigation result', () => {
|
||||
const result: GitLabInvestigationResult = {
|
||||
success: true,
|
||||
issueIid: 1,
|
||||
analysis: {
|
||||
summary: 'Test summary',
|
||||
proposedSolution: 'Fix the bug',
|
||||
affectedFiles: ['file.ts'],
|
||||
estimatedComplexity: 'simple',
|
||||
acceptanceCriteria: ['Test passes']
|
||||
}
|
||||
};
|
||||
useInvestigationStore.getState().setInvestigationResult(result);
|
||||
expect(useInvestigationStore.getState().lastInvestigationResult?.issueIid).toBe(1);
|
||||
});
|
||||
|
||||
it('should clear investigation', () => {
|
||||
useInvestigationStore.getState().setInvestigationStatus({
|
||||
phase: 'fetching',
|
||||
progress: 50,
|
||||
message: 'Testing'
|
||||
});
|
||||
useInvestigationStore.getState().clearInvestigation();
|
||||
|
||||
const state = useInvestigationStore.getState();
|
||||
expect(state.investigationStatus.phase).toBe('idle');
|
||||
expect(state.lastInvestigationResult).toBe(null);
|
||||
});
|
||||
|
||||
it('should handle error phase', () => {
|
||||
const status: GitLabInvestigationStatus = {
|
||||
phase: 'error',
|
||||
issueIid: 1,
|
||||
progress: 0,
|
||||
message: 'Investigation failed',
|
||||
error: 'Network error'
|
||||
};
|
||||
useInvestigationStore.getState().setInvestigationStatus(status);
|
||||
expect(useInvestigationStore.getState().investigationStatus.phase).toBe('error');
|
||||
});
|
||||
|
||||
it('should handle creating_task phase', () => {
|
||||
const status: GitLabInvestigationStatus = {
|
||||
phase: 'creating_task',
|
||||
issueIid: 1,
|
||||
progress: 80,
|
||||
message: 'Creating task...'
|
||||
};
|
||||
useInvestigationStore.getState().setInvestigationStatus(status);
|
||||
expect(useInvestigationStore.getState().investigationStatus.phase).toBe('creating_task');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Unit tests for GitLab issues store
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { useIssuesStore } from '../issues-store';
|
||||
import type { GitLabIssue } from '@shared/types';
|
||||
|
||||
/**
|
||||
* Mock factory for GitLab issues
|
||||
* Follows existing codebase pattern from github-prs/hooks/__tests__
|
||||
*/
|
||||
function createMockGitLabIssue(overrides: Partial<GitLabIssue> = {}): 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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Unit tests for GitLab sync status store
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { useSyncStatusStore, checkGitLabConnection } from '../sync-status-store';
|
||||
import type { GitLabSyncStatus } from '@shared/types';
|
||||
|
||||
// Mock electronAPI
|
||||
const mockElectronAPI = {
|
||||
checkGitLabConnection: vi.fn()
|
||||
};
|
||||
|
||||
describe('sync-status-store', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('window', {
|
||||
...(globalThis.window ?? {}),
|
||||
electronAPI: mockElectronAPI
|
||||
} as unknown as Window & typeof globalThis);
|
||||
useSyncStatusStore.getState().clearSyncStatus();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('should initialize with empty state', () => {
|
||||
const state = useSyncStatusStore.getState();
|
||||
expect(state.syncStatus).toBe(null);
|
||||
expect(state.connectionError).toBe(null);
|
||||
});
|
||||
|
||||
it('should set sync status', () => {
|
||||
const status: GitLabSyncStatus = {
|
||||
connected: true,
|
||||
projectPathWithNamespace: 'group/project'
|
||||
};
|
||||
useSyncStatusStore.getState().setSyncStatus(status);
|
||||
expect(useSyncStatusStore.getState().syncStatus).toEqual(status);
|
||||
});
|
||||
|
||||
it('should check connection status', () => {
|
||||
useSyncStatusStore.getState().setSyncStatus({
|
||||
connected: true,
|
||||
projectPathWithNamespace: 'group/project'
|
||||
});
|
||||
expect(useSyncStatusStore.getState().isConnected()).toBe(true);
|
||||
expect(useSyncStatusStore.getState().getProjectPath()).toBe('group/project');
|
||||
});
|
||||
|
||||
it('should handle disconnected state', () => {
|
||||
useSyncStatusStore.getState().setSyncStatus({
|
||||
connected: false,
|
||||
projectPathWithNamespace: undefined
|
||||
});
|
||||
expect(useSyncStatusStore.getState().isConnected()).toBe(false);
|
||||
expect(useSyncStatusStore.getState().getProjectPath()).toBe(null);
|
||||
});
|
||||
|
||||
it('should set connection error', () => {
|
||||
useSyncStatusStore.getState().setConnectionError('Connection failed');
|
||||
expect(useSyncStatusStore.getState().connectionError).toBe('Connection failed');
|
||||
});
|
||||
|
||||
it('should clear sync status', () => {
|
||||
useSyncStatusStore.getState().setSyncStatus({
|
||||
connected: true,
|
||||
projectPathWithNamespace: 'group/project'
|
||||
});
|
||||
useSyncStatusStore.getState().clearSyncStatus();
|
||||
|
||||
expect(useSyncStatusStore.getState().syncStatus).toBe(null);
|
||||
expect(useSyncStatusStore.getState().connectionError).toBe(null);
|
||||
});
|
||||
|
||||
describe('checkGitLabConnection', () => {
|
||||
it('should update store on successful connection', async () => {
|
||||
mockElectronAPI.checkGitLabConnection.mockResolvedValue({
|
||||
success: true,
|
||||
data: {
|
||||
connected: true,
|
||||
projectPathWithNamespace: 'group/project'
|
||||
}
|
||||
});
|
||||
|
||||
const result = await checkGitLabConnection('project-123');
|
||||
|
||||
expect(result).toEqual({
|
||||
connected: true,
|
||||
projectPathWithNamespace: 'group/project'
|
||||
});
|
||||
expect(useSyncStatusStore.getState().syncStatus).toEqual({
|
||||
connected: true,
|
||||
projectPathWithNamespace: 'group/project'
|
||||
});
|
||||
expect(useSyncStatusStore.getState().connectionError).toBe(null);
|
||||
});
|
||||
|
||||
it('should set error on failed connection', async () => {
|
||||
mockElectronAPI.checkGitLabConnection.mockResolvedValue({
|
||||
success: false,
|
||||
error: 'Authentication failed'
|
||||
});
|
||||
|
||||
const result = await checkGitLabConnection('project-123');
|
||||
|
||||
expect(result).toBe(null);
|
||||
expect(useSyncStatusStore.getState().syncStatus).toBe(null);
|
||||
expect(useSyncStatusStore.getState().connectionError).toBe('Authentication failed');
|
||||
});
|
||||
|
||||
it('should set error when connected is false', async () => {
|
||||
mockElectronAPI.checkGitLabConnection.mockResolvedValue({
|
||||
success: true,
|
||||
data: {
|
||||
connected: false,
|
||||
error: 'Project not found'
|
||||
}
|
||||
});
|
||||
|
||||
const result = await checkGitLabConnection('project-123');
|
||||
|
||||
expect(result).toBe(null);
|
||||
expect(useSyncStatusStore.getState().syncStatus).toBe(null);
|
||||
expect(useSyncStatusStore.getState().connectionError).toBe('Project not found');
|
||||
});
|
||||
|
||||
it('should set error on exception', async () => {
|
||||
mockElectronAPI.checkGitLabConnection.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
const result = await checkGitLabConnection('project-123');
|
||||
|
||||
expect(result).toBe(null);
|
||||
expect(useSyncStatusStore.getState().syncStatus).toBe(null);
|
||||
expect(useSyncStatusStore.getState().connectionError).toBe('Network error');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,14 +4,38 @@
|
||||
* This module exports all GitLab-related stores and their utilities.
|
||||
*/
|
||||
|
||||
// Issues Store
|
||||
export {
|
||||
useIssuesStore,
|
||||
loadGitLabIssues,
|
||||
importGitLabIssues,
|
||||
type IssueFilterState
|
||||
} from './issues-store';
|
||||
|
||||
// MR Review Store
|
||||
export {
|
||||
useMRReviewStore,
|
||||
initializeMRReviewListeners,
|
||||
cleanupMRReviewListeners,
|
||||
startMRReview,
|
||||
startFollowupReview
|
||||
} from './mr-review-store';
|
||||
import { initializeMRReviewListeners as _initMRReviewListeners } from './mr-review-store';
|
||||
import {
|
||||
initializeMRReviewListeners as _initMRReviewListeners,
|
||||
cleanupMRReviewListeners as _cleanupMRReviewListeners
|
||||
} from './mr-review-store';
|
||||
|
||||
// Investigation Store
|
||||
export {
|
||||
useInvestigationStore,
|
||||
investigateGitLabIssue
|
||||
} from './investigation-store';
|
||||
|
||||
// Sync Status Store
|
||||
export {
|
||||
useSyncStatusStore,
|
||||
checkGitLabConnection
|
||||
} from './sync-status-store';
|
||||
|
||||
/**
|
||||
* Initialize all global GitLab listeners.
|
||||
@@ -22,6 +46,15 @@ export function initializeGitLabListeners(): void {
|
||||
// Add other global listeners here as needed
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup all global GitLab listeners.
|
||||
* Call this during app unmount or hot-reload.
|
||||
*/
|
||||
export function cleanupGitLabListeners(): void {
|
||||
_cleanupMRReviewListeners();
|
||||
// Add other cleanup implementations here as needed
|
||||
}
|
||||
|
||||
// Re-export types for convenience
|
||||
export type {
|
||||
GitLabMRReviewProgress,
|
||||
@@ -30,5 +63,6 @@ export type {
|
||||
GitLabMergeRequest,
|
||||
GitLabSyncStatus,
|
||||
GitLabInvestigationStatus,
|
||||
GitLabInvestigationResult
|
||||
GitLabInvestigationResult,
|
||||
GitLabIssue
|
||||
} from '../../../shared/types';
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* GitLab Investigation Store
|
||||
*
|
||||
* Tracks investigation state for GitLab issues.
|
||||
* Mirrors github investigation patterns.
|
||||
*/
|
||||
import { create } from 'zustand';
|
||||
import type {
|
||||
GitLabInvestigationStatus,
|
||||
GitLabInvestigationResult
|
||||
} from '@shared/types';
|
||||
|
||||
interface InvestigationState {
|
||||
// Investigation state
|
||||
investigationStatus: GitLabInvestigationStatus;
|
||||
lastInvestigationResult: GitLabInvestigationResult | null;
|
||||
|
||||
// Actions
|
||||
setInvestigationStatus: (status: GitLabInvestigationStatus) => void;
|
||||
setInvestigationResult: (result: GitLabInvestigationResult | null) => void;
|
||||
clearInvestigation: () => void;
|
||||
}
|
||||
|
||||
export const useInvestigationStore = create<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);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* GitLab Issues Store
|
||||
*
|
||||
* Manages GitLab issue state with filtering.
|
||||
* Mirrors the structure of github/issues-store.ts.
|
||||
*
|
||||
* Note: Pagination support will be added with IPC handlers (Task 8)
|
||||
*/
|
||||
import { create } from 'zustand';
|
||||
import type { GitLabIssue } from '@shared/types';
|
||||
import type { GitLabFilterState } from '@shared/integrations/types/base-types';
|
||||
|
||||
// GitLab issues don't have 'merged' state (only MRs do), so create a specific type
|
||||
export type IssueFilterState = Exclude<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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* GitLab Sync Status Store
|
||||
*
|
||||
* Tracks GitLab connection status for a project.
|
||||
* Mirrors github sync status patterns.
|
||||
*/
|
||||
import { create } from 'zustand';
|
||||
import type { GitLabSyncStatus } from '@shared/types';
|
||||
|
||||
interface SyncStatusState {
|
||||
// Sync status
|
||||
syncStatus: GitLabSyncStatus | null;
|
||||
connectionError: string | null;
|
||||
|
||||
// Actions
|
||||
setSyncStatus: (status: GitLabSyncStatus | null) => void;
|
||||
setConnectionError: (error: string | null) => void;
|
||||
clearSyncStatus: () => void;
|
||||
|
||||
// Selectors
|
||||
isConnected: () => boolean;
|
||||
getProjectPath: () => string | null; // Returns projectPathWithNamespace
|
||||
}
|
||||
|
||||
export const useSyncStatusStore = create<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,12 +95,13 @@ export async function loadReleaseableVersions(projectId: string): Promise<void>
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.getReleaseableVersions(projectId);
|
||||
if (result.success && result.data) {
|
||||
store.setReleaseableVersions(result.data);
|
||||
if (result.success && result.data && Array.isArray(result.data)) {
|
||||
const versions = result.data as ReleaseableVersion[];
|
||||
store.setReleaseableVersions(versions);
|
||||
|
||||
// Auto-select first unreleased version if none selected
|
||||
if (!store.selectedVersion) {
|
||||
const firstUnreleased = result.data.find((v: ReleaseableVersion) => !v.isReleased);
|
||||
const firstUnreleased = versions.find((v: ReleaseableVersion) => !v.isReleased);
|
||||
if (firstUnreleased) {
|
||||
store.setSelectedVersion(firstUnreleased.version);
|
||||
}
|
||||
@@ -132,8 +133,8 @@ export async function runPreflightCheck(projectId: string): Promise<void> {
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.runReleasePreflightCheck(projectId, version);
|
||||
if (result.success && result.data) {
|
||||
store.setPreflightStatus(result.data);
|
||||
if (result.success && result.data && typeof result.data === 'object' && !Array.isArray(result.data)) {
|
||||
store.setPreflightStatus(result.data as ReleasePreflightStatus);
|
||||
} else {
|
||||
store.setError(result.error || 'Failed to run pre-flight checks');
|
||||
}
|
||||
|
||||
@@ -76,6 +76,7 @@ export const IPC_CHANNELS = {
|
||||
TERMINAL_GENERATE_NAME: 'terminal:generateName',
|
||||
TERMINAL_SET_TITLE: 'terminal:setTitle', // Renderer -> Main: user renamed terminal
|
||||
TERMINAL_SET_WORKTREE_CONFIG: 'terminal:setWorktreeConfig', // Renderer -> Main: worktree association changed
|
||||
TERMINAL_SAVE_BUFFER: 'terminal:saveBuffer', // Renderer -> Main: save terminal buffer content
|
||||
|
||||
// Terminal session management
|
||||
TERMINAL_GET_SESSIONS: 'terminal:getSessions',
|
||||
@@ -333,11 +334,22 @@ export const IPC_CHANNELS = {
|
||||
GITLAB_MR_ASSIGN: 'gitlab:mr:assign',
|
||||
GITLAB_MR_APPROVE: 'gitlab:mr:approve',
|
||||
GITLAB_MR_CHECK_NEW_COMMITS: 'gitlab:mr:checkNewCommits',
|
||||
GITLAB_MR_LIST_MORE: 'gitlab:mr:listMore', // Load more MRs (pagination)
|
||||
GITLAB_MR_DELETE_REVIEW: 'gitlab:mr:deleteReview', // Delete a posted review
|
||||
GITLAB_MR_CHECK_MERGE_READINESS: 'gitlab:mr:checkMergeReadiness', // Check if MR can be merged
|
||||
GITLAB_MR_GET_REVIEWS_BATCH: 'gitlab:mr:getReviewsBatch', // Batch load reviews for multiple MRs
|
||||
GITLAB_MR_FIX: 'gitlab:mr:fix', // Auto-fix issues in MR
|
||||
GITLAB_MR_GET_LOGS: 'gitlab:mr:getLogs', // Get AI review logs
|
||||
GITLAB_MR_STATUS_POLL_START: 'gitlab:mr:statusPollStart', // Start polling MR status
|
||||
GITLAB_MR_STATUS_POLL_STOP: 'gitlab:mr:statusPollStop', // Stop polling MR status
|
||||
GITLAB_MR_MEMORY_GET: 'gitlab:mr:memory:get', // Get MR review memories
|
||||
GITLAB_MR_MEMORY_SEARCH: 'gitlab:mr:memory:search', // Search MR review memories
|
||||
|
||||
// GitLab MR Review events (main -> renderer)
|
||||
GITLAB_MR_REVIEW_PROGRESS: 'gitlab:mr:reviewProgress',
|
||||
GITLAB_MR_REVIEW_COMPLETE: 'gitlab:mr:reviewComplete',
|
||||
GITLAB_MR_REVIEW_ERROR: 'gitlab:mr:reviewError',
|
||||
GITLAB_MR_STATUS_UPDATE: 'gitlab:mr:statusUpdate',
|
||||
|
||||
// GitLab Auto-Fix operations
|
||||
GITLAB_AUTOFIX_START: 'gitlab:autofix:start',
|
||||
@@ -469,6 +481,11 @@ export const IPC_CHANNELS = {
|
||||
OLLAMA_PULL_MODEL: 'ollama:pullModel',
|
||||
OLLAMA_PULL_PROGRESS: 'ollama:pullProgress',
|
||||
|
||||
// Memory Infrastructure operations (LadybugDB - libSQL)
|
||||
INFRASTRUCTURE_GET_STATUS: 'infrastructure:getStatus',
|
||||
INFRASTRUCTURE_LIST_DATABASES: 'infrastructure:listDatabases',
|
||||
INFRASTRUCTURE_TEST_CONNECTION: 'infrastructure:testConnection',
|
||||
|
||||
// Changelog operations
|
||||
CHANGELOG_GET_DONE_TASKS: 'changelog:getDoneTasks',
|
||||
CHANGELOG_LOAD_TASK_SPECS: 'changelog:loadTaskSpecs',
|
||||
|
||||
@@ -204,5 +204,104 @@
|
||||
"performance": "Performance",
|
||||
"logic": "Logic"
|
||||
}
|
||||
},
|
||||
"autoFix": {
|
||||
"specCreated": "Spec created from issue",
|
||||
"autoFixFailed": "Auto-fix failed",
|
||||
"retryAutoFix": "Retry Auto Fix",
|
||||
"processing": "Processing...",
|
||||
"autoFix": "Auto Fix"
|
||||
},
|
||||
"batchReview": {
|
||||
"title": "Analyze & Group Issues",
|
||||
"description": "This will analyze up to 200 open issues, group similar ones together, and let you review the proposed batches before creating any tasks.",
|
||||
"startAnalysis": "Start Analysis",
|
||||
"analyzing": "Analyzing Issues...",
|
||||
"computingSimilarity": "Computing similarity and validating batches...",
|
||||
"percentComplete": "{{value}}% complete",
|
||||
"issuesAnalyzed": "issues analyzed",
|
||||
"batchesProposed": "batches proposed",
|
||||
"singleIssues": "single issues",
|
||||
"selectSingleIssues": "Single Issues (not grouped)",
|
||||
"andMore": "...and {{count}} more",
|
||||
"batchesSelected": "{{count}} batch(es) selected ({{issues}} issues)",
|
||||
"plusSingleIssues": "+ {{count}} single issue(s)",
|
||||
"creatingBatches": "Creating Batches...",
|
||||
"settingUpBatches": "Setting up the approved issue batches for processing.",
|
||||
"batchesCreated": "Batches Created",
|
||||
"batchesReady": "Your selected issue batches are ready for processing.",
|
||||
"close": "Close",
|
||||
"cancel": "Cancel",
|
||||
"approveAndCreate": "Approve & Create ({{count}} batch(es))",
|
||||
"creating": "Creating...",
|
||||
"selectDeselectAll": "Select All",
|
||||
"deselectAll": "Deselect All",
|
||||
"issues": "issues",
|
||||
"similar": "% similar",
|
||||
"similarPercent": "{{value}}% similar",
|
||||
"batchNumber": "Batch {{number}}"
|
||||
},
|
||||
"errors": {
|
||||
"AUTHENTICATION_FAILED": "GitLab authentication failed. Please check your access token.",
|
||||
"RATE_LIMITED": "GitLab rate limit exceeded. Please wait a moment before trying again.",
|
||||
"NETWORK_ERROR": "Network error. Please check your connection and try again.",
|
||||
"PROJECT_NOT_FOUND": "GitLab project not found. Please check your project configuration.",
|
||||
"INSUFFICIENT_PERMISSIONS": "Insufficient permissions. Please check your GitLab access token scopes.",
|
||||
"CONFLICT": "There was a conflict with the current state of the resource.",
|
||||
"UNKNOWN": "An unknown error occurred"
|
||||
},
|
||||
"mrFiltering": {
|
||||
"reviewed": "Reviewed",
|
||||
"posted": "Posted",
|
||||
"changesRequested": "Changes Requested",
|
||||
"readyToMerge": "Ready to Merge",
|
||||
"readyForFollowup": "Ready for Follow-up",
|
||||
"reviewing": "Reviewing",
|
||||
"notReviewed": "Not Reviewed",
|
||||
"searchPlaceholder": "Search merge requests...",
|
||||
"contributors": "Contributors",
|
||||
"searchContributors": "Search contributors...",
|
||||
"selectedCount": "{{count}} selected",
|
||||
"noResultsFound": "No results found",
|
||||
"clearFilters": "Clear filters",
|
||||
"allStatuses": "All Statuses",
|
||||
"clearSearch": "Clear search",
|
||||
"reset": "Reset",
|
||||
"sort": {
|
||||
"label": "Sort",
|
||||
"newest": "Newest",
|
||||
"oldest": "Oldest",
|
||||
"largest": "Largest"
|
||||
},
|
||||
"logs": {
|
||||
"mrLabel": "MR #{{iid}}",
|
||||
"agentActivity": "Agent Activity",
|
||||
"showMore": "Show {{count}} more",
|
||||
"hideMore": "Hide {{count}} more",
|
||||
"noLogsYet": "No logs yet",
|
||||
"waitingForLogs": "Waiting for logs...",
|
||||
"reviewStarting": "Review is starting",
|
||||
"noLogsAvailable": "No logs available",
|
||||
"runReviewGenerateLogs": "Run a review to generate logs",
|
||||
"entries": "entries",
|
||||
"less": "Less",
|
||||
"more": "More",
|
||||
"followup": "Follow-up",
|
||||
"live": "Live",
|
||||
"streaming": "Streaming",
|
||||
"running": "Running",
|
||||
"pending": "Pending",
|
||||
"complete": "Complete",
|
||||
"failed": "Failed",
|
||||
"contextGathering": "Context Gathering",
|
||||
"aiAnalysis": "AI Analysis",
|
||||
"synthesis": "Synthesis",
|
||||
"filesRead": "{{count}} file read",
|
||||
"filesRead_plural": "{{count}} files read",
|
||||
"searches": "{{count}} search",
|
||||
"searches_plural": "{{count}} searches",
|
||||
"other": "{{count}} other",
|
||||
"operations": "{{count}} operations"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,5 +214,104 @@
|
||||
"performance": "Performance",
|
||||
"logic": "Logique"
|
||||
}
|
||||
},
|
||||
"autoFix": {
|
||||
"specCreated": "Spécification créée à partir de l'issue",
|
||||
"autoFixFailed": "Échec de la correction automatique",
|
||||
"retryAutoFix": "Réessayer la correction automatique",
|
||||
"processing": "Traitement...",
|
||||
"autoFix": "Correction automatique"
|
||||
},
|
||||
"batchReview": {
|
||||
"title": "Analyser et grouper les issues",
|
||||
"description": "Cela analysera jusqu'à 200 issues ouvertes, regroupera celles similaires et vous permettra de vérifier les lots proposés avant de créer des tâches.",
|
||||
"startAnalysis": "Démarrer l'analyse",
|
||||
"analyzing": "Analyse des issues...",
|
||||
"computingSimilarity": "Calcul de la similarité et validation des lots...",
|
||||
"percentComplete": "{{value}}% complété",
|
||||
"issuesAnalyzed": "issues analysées",
|
||||
"batchesProposed": "lots proposés",
|
||||
"singleIssues": "issues seules",
|
||||
"selectSingleIssues": "Issues seules (non groupées)",
|
||||
"andMore": "...et {{count}} autres",
|
||||
"batchesSelected": "{{count}} lot(s) sélectionné(s) ({{issues}} issues)",
|
||||
"plusSingleIssues": "+ {{count}} issue(s) seule(s)",
|
||||
"creatingBatches": "Création des lots...",
|
||||
"settingUpBatches": "Configuration des lots d'issues approuvés pour le traitement.",
|
||||
"batchesCreated": "Lots créés",
|
||||
"batchesReady": "Vos lots d'issues sélectionnés sont prêts pour le traitement.",
|
||||
"close": "Fermer",
|
||||
"cancel": "Annuler",
|
||||
"approveAndCreate": "Approuver et créer ({{count}} lot(s))",
|
||||
"creating": "Création...",
|
||||
"selectDeselectAll": "Tout sélectionner",
|
||||
"deselectAll": "Tout désélectionner",
|
||||
"issues": "issues",
|
||||
"similar": "% similaire",
|
||||
"similarPercent": "{{value}}% similaire",
|
||||
"batchNumber": "Lot {{number}}"
|
||||
},
|
||||
"errors": {
|
||||
"AUTHENTICATION_FAILED": "Échec de l'authentification GitLab. Veuillez vérifier votre token d'accès.",
|
||||
"RATE_LIMITED": "Limite de taux GitLab dépassée. Veuillez attendre un moment avant de réessayer.",
|
||||
"NETWORK_ERROR": "Erreur réseau. Veuillez vérifier votre connexion et réessayer.",
|
||||
"PROJECT_NOT_FOUND": "Projet GitLab introuvable. Veuillez vérifier la configuration de votre projet.",
|
||||
"INSUFFICIENT_PERMISSIONS": "Autorisations insuffisantes. Veuillez vérifier les scopes de votre token d'accès GitLab.",
|
||||
"CONFLICT": "Il y a eu un conflit avec l'état actuel de la ressource.",
|
||||
"UNKNOWN": "Une erreur inconnue s'est produite"
|
||||
},
|
||||
"mrFiltering": {
|
||||
"reviewed": "Analysée",
|
||||
"posted": "Publié",
|
||||
"changesRequested": "Modifications demandées",
|
||||
"readyToMerge": "Prête à fusionner",
|
||||
"readyForFollowup": "Prête pour suivi",
|
||||
"reviewing": "Analyse en cours",
|
||||
"notReviewed": "Non analysée",
|
||||
"searchPlaceholder": "Rechercher des merge requests...",
|
||||
"contributors": "Contributeurs",
|
||||
"searchContributors": "Rechercher des contributeurs...",
|
||||
"selectedCount": "{{count}} sélectionné(s)",
|
||||
"noResultsFound": "Aucun résultat trouvé",
|
||||
"clearFilters": "Effacer les filtres",
|
||||
"allStatuses": "Tous les statuts",
|
||||
"clearSearch": "Effacer la recherche",
|
||||
"reset": "Réinitialiser",
|
||||
"sort": {
|
||||
"label": "Trier",
|
||||
"newest": "Plus récent",
|
||||
"oldest": "Plus ancien",
|
||||
"largest": "Plus grand"
|
||||
},
|
||||
"logs": {
|
||||
"mrLabel": "MR n°{{iid}}",
|
||||
"agentActivity": "Activité de l'agent",
|
||||
"showMore": "Afficher {{count}} autres",
|
||||
"hideMore": "Masquer {{count}} autres",
|
||||
"noLogsYet": "Pas encore de logs",
|
||||
"waitingForLogs": "En attente des logs...",
|
||||
"reviewStarting": "La revue commence",
|
||||
"noLogsAvailable": "Aucun log disponible",
|
||||
"runReviewGenerateLogs": "Lancez une revue pour générer des logs",
|
||||
"entries": "entrées",
|
||||
"less": "Moins",
|
||||
"more": "Plus",
|
||||
"followup": "Suivi",
|
||||
"live": "En direct",
|
||||
"streaming": "Diffusion",
|
||||
"running": "En cours",
|
||||
"pending": "En attente",
|
||||
"complete": "Terminé",
|
||||
"failed": "Échoué",
|
||||
"contextGathering": "Collecte du contexte",
|
||||
"aiAnalysis": "Analyse IA",
|
||||
"synthesis": "Synthèse",
|
||||
"filesRead": "{{count}} fichier lu",
|
||||
"filesRead_plural": "{{count}} fichiers lus",
|
||||
"searches": "{{count}} recherche",
|
||||
"searches_plural": "{{count}} recherches",
|
||||
"other": "{{count}} autre",
|
||||
"operations": "{{count}} opérations"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { applyFilter, getFilterPredicate, isValidFilterState } from '../filter-utils';
|
||||
import type { FilterState } from '../../types/base-types';
|
||||
|
||||
describe('filter-utils', () => {
|
||||
interface TestItem {
|
||||
id: number;
|
||||
state: string; // Use string to accommodate different state formats
|
||||
}
|
||||
|
||||
it('should filter by opened state', () => {
|
||||
const items: TestItem[] = [
|
||||
{ id: 1, state: 'opened' },
|
||||
{ id: 2, state: 'closed' },
|
||||
{ id: 3, state: 'opened' }
|
||||
];
|
||||
const result = applyFilter(items, 'opened');
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.every((i: TestItem) => i.state === 'opened')).toBe(true);
|
||||
});
|
||||
|
||||
it('should normalize open/opened states (GitHub vs GitLab)', () => {
|
||||
const items: TestItem[] = [
|
||||
{ id: 1, state: 'open' }, // GitHub format
|
||||
{ id: 2, state: 'opened' }, // GitLab format
|
||||
{ id: 3, state: 'closed' }
|
||||
];
|
||||
// Filter with 'opened' should match both 'open' and 'opened'
|
||||
const result = applyFilter(items, 'opened');
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((i: TestItem) => i.id)).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('should handle GitHub native open filter (regression)', () => {
|
||||
const items: TestItem[] = [
|
||||
{ id: 1, state: 'open' }, // GitHub format
|
||||
{ id: 2, state: 'opened' }, // GitLab format
|
||||
{ id: 3, state: 'closed' }
|
||||
];
|
||||
// Filter with 'open' (GitHub native) should also match both
|
||||
const result = applyFilter(items, 'open');
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((i: TestItem) => i.id)).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('should return all items for "all" filter', () => {
|
||||
const items: TestItem[] = [
|
||||
{ id: 1, state: 'opened' },
|
||||
{ id: 2, state: 'closed' }
|
||||
];
|
||||
const result = applyFilter(items, 'all');
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should create filter predicate', () => {
|
||||
const predicate = getFilterPredicate('opened');
|
||||
expect(predicate({ state: 'opened' } as TestItem)).toBe(true);
|
||||
expect(predicate({ state: 'open' } as TestItem)).toBe(true); // Normalized
|
||||
expect(predicate({ state: 'closed' } as TestItem)).toBe(false);
|
||||
});
|
||||
|
||||
it('should filter by closed state', () => {
|
||||
const items: TestItem[] = [
|
||||
{ id: 1, state: 'opened' },
|
||||
{ id: 2, state: 'closed' },
|
||||
{ id: 3, state: 'opened' }
|
||||
];
|
||||
const result = applyFilter(items, 'closed');
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].state).toBe('closed');
|
||||
});
|
||||
|
||||
it('should filter by merged state', () => {
|
||||
const items: TestItem[] = [
|
||||
{ id: 1, state: 'opened' },
|
||||
{ id: 2, state: 'merged' },
|
||||
{ id: 3, state: 'closed' }
|
||||
];
|
||||
const result = applyFilter(items, 'merged');
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].state).toBe('merged');
|
||||
});
|
||||
|
||||
it('should validate filter states', () => {
|
||||
// Both 'open' and 'opened' should be valid
|
||||
expect(isValidFilterState('open')).toBe(true);
|
||||
expect(isValidFilterState('opened')).toBe(true);
|
||||
expect(isValidFilterState('closed')).toBe(true);
|
||||
expect(isValidFilterState('merged')).toBe(true);
|
||||
expect(isValidFilterState('all')).toBe(true);
|
||||
// Invalid states
|
||||
expect(isValidFilterState('invalid')).toBe(false);
|
||||
expect(isValidFilterState('')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Shared filter utilities for integrations
|
||||
*
|
||||
* Handles platform differences:
|
||||
* - GitHub uses 'open', GitLab uses 'opened' for active state
|
||||
* - Both use 'closed' and 'all'
|
||||
* - GitLab MRs additionally have 'merged' state
|
||||
*/
|
||||
import type { FilterState } from '../types/base-types';
|
||||
|
||||
export interface Filterable {
|
||||
state: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize 'open' and 'opened' to a common key for comparison
|
||||
* GitHub uses 'open', GitLab uses 'opened' - treat them as equivalent
|
||||
*/
|
||||
function normalizeState(state: string): string {
|
||||
if (state === 'open' || state === 'opened') {
|
||||
return 'open'; // Normalize to 'open' for comparison
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
export function applyFilter<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);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { calculateHasMore, appendWithoutDuplicates, getNextPage, resetPagination } from '../pagination-utils';
|
||||
|
||||
describe('pagination-utils', () => {
|
||||
it('should calculate hasMore correctly', () => {
|
||||
expect(calculateHasMore(100, 50)).toBe(true);
|
||||
expect(calculateHasMore(50, 50)).toBe(false);
|
||||
expect(calculateHasMore(10, 50)).toBe(false);
|
||||
});
|
||||
|
||||
it('should append items without duplicates', () => {
|
||||
const existing = [{ id: 1 }, { id: 2 }];
|
||||
const newItems = [{ id: 2 }, { id: 3 }];
|
||||
const result = appendWithoutDuplicates(existing, newItems, 'id');
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result.map((i: { id: number }) => i.id)).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it('should handle empty arrays', () => {
|
||||
const result = appendWithoutDuplicates([], [{ id: 1 }], 'id');
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should handle all duplicates', () => {
|
||||
const existing = [{ id: 1 }, { id: 2 }];
|
||||
const newItems = [{ id: 1 }, { id: 2 }];
|
||||
const result = appendWithoutDuplicates(existing, newItems, 'id');
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should get next page', () => {
|
||||
expect(getNextPage(1)).toBe(2);
|
||||
expect(getNextPage(5)).toBe(6);
|
||||
expect(getNextPage(0)).toBe(1);
|
||||
});
|
||||
|
||||
it('should reset pagination', () => {
|
||||
const result = resetPagination();
|
||||
expect(result.currentPage).toBe(1);
|
||||
expect(result.hasMore).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Shared pagination utilities for integrations
|
||||
*/
|
||||
|
||||
/**
|
||||
* Determine if there are more items to load based on total count and page size.
|
||||
* Returns true if totalCount exceeds pageSize, indicating additional pages exist.
|
||||
* @param totalCount - Total number of items available
|
||||
* @param pageSize - Number of items per page
|
||||
* @returns true if there are more pages to load
|
||||
*/
|
||||
export function calculateHasMore(totalCount: number, pageSize: number): boolean {
|
||||
return totalCount > pageSize;
|
||||
}
|
||||
|
||||
export function appendWithoutDuplicates<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
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { IntegrationError, SyncStatus, InvestigationStatus } from '../base-types';
|
||||
|
||||
describe('base-types', () => {
|
||||
it('should create IntegrationError', () => {
|
||||
const error: IntegrationError = {
|
||||
code: 'RATE_LIMITED',
|
||||
message: 'Too many requests',
|
||||
recoverable: true
|
||||
};
|
||||
expect(error.code).toBe('RATE_LIMITED');
|
||||
expect(error.recoverable).toBe(true);
|
||||
});
|
||||
|
||||
it('should create SyncStatus', () => {
|
||||
const status: SyncStatus = {
|
||||
connected: true,
|
||||
repoFullName: 'group/project'
|
||||
};
|
||||
expect(status.connected).toBe(true);
|
||||
});
|
||||
|
||||
it('should create InvestigationStatus', () => {
|
||||
const status: InvestigationStatus = {
|
||||
phase: 'fetching',
|
||||
progress: 50,
|
||||
message: 'Analyzing...'
|
||||
};
|
||||
expect(status.phase).toBe('fetching');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Shared integration types for GitHub and GitLab
|
||||
*/
|
||||
|
||||
export interface IntegrationError {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: unknown;
|
||||
recoverable: boolean;
|
||||
}
|
||||
|
||||
export interface SyncStatus {
|
||||
connected: boolean;
|
||||
repoFullName: string | null;
|
||||
}
|
||||
|
||||
export interface InvestigationStatus {
|
||||
phase: 'idle' | 'fetching' | 'analyzing' | 'complete' | 'error';
|
||||
progress: number;
|
||||
message: string;
|
||||
issueNumber?: number;
|
||||
mrIid?: number;
|
||||
}
|
||||
|
||||
export interface InvestigationResult {
|
||||
issueNumber?: number;
|
||||
mrIid?: number;
|
||||
summary: string;
|
||||
findings: string[];
|
||||
relatedFiles: string[];
|
||||
suggestedActions: string[];
|
||||
}
|
||||
|
||||
export interface PaginationState {
|
||||
currentPage: number;
|
||||
hasMore: boolean;
|
||||
isLoadingMore: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Platform-specific filter states
|
||||
* GitHub uses 'open', GitLab uses 'opened'
|
||||
* Both use 'closed' and 'all'
|
||||
* GitLab additionally has 'merged' for MRs
|
||||
*/
|
||||
export type GitHubFilterState = 'open' | 'closed' | 'all';
|
||||
export type GitLabFilterState = 'opened' | 'closed' | 'merged' | 'all';
|
||||
export type FilterState = GitHubFilterState | GitLabFilterState;
|
||||
@@ -324,6 +324,7 @@ export interface GitLabMRReviewProgress {
|
||||
|
||||
export interface GitLabNewCommitsCheck {
|
||||
hasNewCommits: boolean;
|
||||
hasCommitsAfterPosting?: boolean; // True if commits were added AFTER the review was posted
|
||||
currentSha?: string;
|
||||
reviewedSha?: string;
|
||||
newCommitCount?: number;
|
||||
@@ -375,6 +376,27 @@ export interface GitLabAutoFixProgress {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface GitLabAnalyzePreviewProgress {
|
||||
message: string;
|
||||
progress: number;
|
||||
}
|
||||
|
||||
export interface GitLabProposedBatch {
|
||||
primaryIssue: number;
|
||||
issues: Array<{
|
||||
iid: number;
|
||||
title: string;
|
||||
labels: string[];
|
||||
similarityToPrimary: number;
|
||||
}>;
|
||||
issueCount: number;
|
||||
commonThemes: string[];
|
||||
validated: boolean;
|
||||
confidence: number;
|
||||
reasoning: string;
|
||||
theme: string;
|
||||
}
|
||||
|
||||
export interface GitLabAnalyzePreviewResult {
|
||||
success: boolean;
|
||||
totalIssues: number;
|
||||
@@ -478,3 +500,19 @@ export interface RoadmapProviderConfig {
|
||||
* Canny-specific status values
|
||||
*/
|
||||
export type CannyStatus = 'open' | 'under review' | 'planned' | 'in progress' | 'complete' | 'closed';
|
||||
|
||||
/**
|
||||
* MR status update event - sent from main process to renderer
|
||||
*/
|
||||
export interface GitLabMRStatusUpdate {
|
||||
/** Project ID */
|
||||
projectId: string;
|
||||
/** Merge request IID */
|
||||
mrIid: number;
|
||||
/** Current state of the MR */
|
||||
state: string;
|
||||
/** Merge status */
|
||||
mergeStatus: string;
|
||||
/** Last update timestamp */
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
@@ -170,6 +170,12 @@ export interface GitBranchDetail {
|
||||
// ============================================
|
||||
|
||||
// Electron API exposed via contextBridge
|
||||
// Import from preload to avoid duplication
|
||||
import type { ElectronAPI as PreloadElectronAPI } from '@preload/api';
|
||||
|
||||
// Re-export ElectronAPI type from preload
|
||||
export type ElectronAPI = PreloadElectronAPI;
|
||||
|
||||
// Tab state interface (persisted in main process)
|
||||
export interface TabState {
|
||||
openProjectIds: string[];
|
||||
@@ -177,7 +183,14 @@ export interface TabState {
|
||||
tabOrder: string[];
|
||||
}
|
||||
|
||||
export interface ElectronAPI {
|
||||
// Legacy: Keep the old interface for reference, but use the imported type above
|
||||
// This will be removed once all references are updated
|
||||
/**
|
||||
* @deprecated This interface is kept for migration reference only.
|
||||
* Use the `ElectronAPI` type alias instead.
|
||||
* TODO: Remove once all references are updated.
|
||||
*/
|
||||
export interface ElectronAPILegacy {
|
||||
// Project operations
|
||||
addProject: (projectPath: string) => Promise<IPCResult<Project>>;
|
||||
removeProject: (projectId: string) => Promise<IPCResult>;
|
||||
|
||||
Reference in New Issue
Block a user