Compare commits
67 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 92b7534db6 | |||
| 76fdbade6f | |||
| 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 |
@@ -36,18 +36,18 @@
|
||||
> ⚠️ Beta releases may contain bugs and breaking changes. [View all releases](https://github.com/AndyMik90/Auto-Claude/releases)
|
||||
|
||||
<!-- BETA_VERSION_BADGE -->
|
||||
[](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.8.0-beta.1)
|
||||
[](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.8.0-beta.5)
|
||||
<!-- BETA_VERSION_BADGE_END -->
|
||||
|
||||
<!-- BETA_DOWNLOADS -->
|
||||
| Platform | Download |
|
||||
|----------|----------|
|
||||
| **Windows** | [Auto-Claude-2.8.0-beta.1-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.8.0-beta.1/Auto-Claude-2.8.0-beta.1-win32-x64.exe) |
|
||||
| **macOS (Apple Silicon)** | [Auto-Claude-2.8.0-beta.1-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.8.0-beta.1/Auto-Claude-2.8.0-beta.1-darwin-arm64.dmg) |
|
||||
| **macOS (Intel)** | [Auto-Claude-2.8.0-beta.1-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.8.0-beta.1/Auto-Claude-2.8.0-beta.1-darwin-x64.dmg) |
|
||||
| **Linux** | [Auto-Claude-2.8.0-beta.1-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.8.0-beta.1/Auto-Claude-2.8.0-beta.1-linux-x86_64.AppImage) |
|
||||
| **Linux (Debian)** | [Auto-Claude-2.8.0-beta.1-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.8.0-beta.1/Auto-Claude-2.8.0-beta.1-linux-amd64.deb) |
|
||||
| **Linux (Flatpak)** | [Auto-Claude-2.8.0-beta.1-linux-x86_64.flatpak](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.8.0-beta.1/Auto-Claude-2.8.0-beta.1-linux-x86_64.flatpak) |
|
||||
| **Windows** | [Aperant-2.8.0-beta.5-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.8.0-beta.5/Aperant-2.8.0-beta.5-win32-x64.exe) |
|
||||
| **macOS (Apple Silicon)** | [Aperant-2.8.0-beta.5-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.8.0-beta.5/Aperant-2.8.0-beta.5-darwin-arm64.dmg) |
|
||||
| **macOS (Intel)** | [Aperant-2.8.0-beta.5-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.8.0-beta.5/Aperant-2.8.0-beta.5-darwin-x64.dmg) |
|
||||
| **Linux** | [Aperant-2.8.0-beta.5-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.8.0-beta.5/Aperant-2.8.0-beta.5-linux-x86_64.AppImage) |
|
||||
| **Linux (Debian)** | [Aperant-2.8.0-beta.5-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.8.0-beta.5/Aperant-2.8.0-beta.5-linux-amd64.deb) |
|
||||
| **Linux (Flatpak)** | [Aperant-2.8.0-beta.5-linux-x86_64.flatpak](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.8.0-beta.5/Aperant-2.8.0-beta.5-linux-x86_64.flatpak) |
|
||||
<!-- BETA_DOWNLOADS_END -->
|
||||
|
||||
> All releases include SHA256 checksums and VirusTotal scan results for security verification.
|
||||
|
||||
@@ -60,4 +60,3 @@ bun.lockb
|
||||
# Test files in root
|
||||
test-*.js
|
||||
test-*.cjs
|
||||
.fix-pr-data/
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
|
||||
@@ -25,12 +28,6 @@ export const app = {
|
||||
class MockIpcMain extends EventEmitter {
|
||||
private handlers: Map<string, Function> = new Map();
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
// Increase maxListeners to accommodate test suites that register many handlers
|
||||
this.setMaxListeners(50);
|
||||
}
|
||||
|
||||
handle(channel: string, handler: Function): void {
|
||||
this.handlers.set(channel, handler);
|
||||
}
|
||||
@@ -43,12 +40,6 @@ class MockIpcMain extends EventEmitter {
|
||||
this.handlers.delete(channel);
|
||||
}
|
||||
|
||||
// Reset all handlers and listeners (for test cleanup)
|
||||
reset(): void {
|
||||
this.handlers.clear();
|
||||
this.removeAllListeners();
|
||||
}
|
||||
|
||||
// Helper for tests to invoke handlers
|
||||
async invokeHandler(channel: string, event: unknown, ...args: unknown[]): Promise<unknown> {
|
||||
const handler = this.handlers.get(channel);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -4,11 +4,6 @@
|
||||
import { vi, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdirSync, rmSync, existsSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
// Increase default maxListeners for all EventEmitters in tests
|
||||
// This prevents MaxListenersExceededWarning when multiple test files register handlers
|
||||
EventEmitter.defaultMaxListeners = 100;
|
||||
|
||||
// Mock localStorage for tests that need it
|
||||
const localStorageMock = (() => {
|
||||
@@ -60,7 +55,9 @@ beforeEach(() => {
|
||||
// Clear localStorage
|
||||
localStorageMock.clear();
|
||||
|
||||
// Each test uses unique test data; parallel tests are isolated by worktree
|
||||
// Use a unique subdirectory per test to avoid race conditions in parallel tests
|
||||
const testId = `test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const _testDir = path.join(TEST_DATA_DIR, testId);
|
||||
|
||||
try {
|
||||
if (existsSync(TEST_DATA_DIR)) {
|
||||
@@ -80,22 +77,9 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
// Clean up test directory after each test
|
||||
afterEach(async () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Reset MockIpcMain to clear all handlers and prevent EventEmitter leaks
|
||||
// Import dynamically to avoid circular import issues
|
||||
try {
|
||||
const electron = await import('electron');
|
||||
const reset = (electron.ipcMain as { reset?: () => void }).reset;
|
||||
if (typeof reset === 'function') {
|
||||
reset();
|
||||
}
|
||||
} catch {
|
||||
// Ignore if mock isn't available
|
||||
} finally {
|
||||
vi.resetModules();
|
||||
}
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
// Mock window.electronAPI for renderer tests
|
||||
|
||||
@@ -88,8 +88,8 @@ describe('resolveModelId', () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset environment variables, explicitly clearing model overrides
|
||||
// that may be set in the test environment
|
||||
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;
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
/**
|
||||
* MemoryObserver Tests
|
||||
*
|
||||
* Tests observe() with mock messages and verifies performance budget.
|
||||
* 50ms threshold catches real regressions while being robust to system load.
|
||||
* Tests observe() with mock messages and verifies the <2ms budget.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
@@ -17,7 +16,7 @@ describe('MemoryObserver', () => {
|
||||
});
|
||||
|
||||
describe('observe() budget', () => {
|
||||
it('processes tool-call messages within 50ms', () => {
|
||||
it('processes tool-call messages within 2ms', () => {
|
||||
const msg: MemoryIpcRequest = {
|
||||
type: 'memory:tool-call',
|
||||
toolName: 'Read',
|
||||
@@ -29,10 +28,10 @@ describe('MemoryObserver', () => {
|
||||
observer.observe(msg);
|
||||
const elapsed = Number(process.hrtime.bigint() - start) / 1_000_000;
|
||||
|
||||
expect(elapsed).toBeLessThan(50);
|
||||
expect(elapsed).toBeLessThan(2);
|
||||
});
|
||||
|
||||
it('processes reasoning messages within 50ms', () => {
|
||||
it('processes reasoning messages within 2ms', () => {
|
||||
const msg: MemoryIpcRequest = {
|
||||
type: 'memory:reasoning',
|
||||
text: 'I need to read the file first to understand the structure.',
|
||||
@@ -43,10 +42,10 @@ describe('MemoryObserver', () => {
|
||||
observer.observe(msg);
|
||||
const elapsed = Number(process.hrtime.bigint() - start) / 1_000_000;
|
||||
|
||||
expect(elapsed).toBeLessThan(50);
|
||||
expect(elapsed).toBeLessThan(2);
|
||||
});
|
||||
|
||||
it('processes step-complete messages within 50ms', () => {
|
||||
it('processes step-complete messages within 2ms', () => {
|
||||
const msg: MemoryIpcRequest = {
|
||||
type: 'memory:step-complete',
|
||||
stepNumber: 5,
|
||||
@@ -56,7 +55,7 @@ describe('MemoryObserver', () => {
|
||||
observer.observe(msg);
|
||||
const elapsed = Number(process.hrtime.bigint() - start) / 1_000_000;
|
||||
|
||||
expect(elapsed).toBeLessThan(50);
|
||||
expect(elapsed).toBeLessThan(2);
|
||||
});
|
||||
|
||||
it('does not throw on malformed messages', () => {
|
||||
|
||||
@@ -1,593 +0,0 @@
|
||||
/**
|
||||
* Parallel Orchestrator Unit Tests
|
||||
* ================================
|
||||
*
|
||||
* Comprehensive test suite for parallel-orchestrator.ts covering:
|
||||
* - Constructor instantiation
|
||||
* - review() method with mocked streamText
|
||||
* - Parallel specialist execution
|
||||
* - Verdict mapping
|
||||
* - Abort signal handling
|
||||
* - Specialist failure handling
|
||||
*
|
||||
* Note: Cross-validation tests are complex due to MD5-based finding IDs.
|
||||
* The tests focus on orchestrator behavior that can be reliably tested.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// =============================================================================
|
||||
// Mocks — must be declared before any imports that use them
|
||||
// =============================================================================
|
||||
|
||||
const mockStreamText = vi.fn();
|
||||
|
||||
vi.mock('ai', () => ({
|
||||
streamText: (...args: unknown[]) => mockStreamText(...args),
|
||||
stepCountIs: vi.fn((count: number) => ({ type: 'step-count', maxSteps: count })),
|
||||
Output: {
|
||||
object: vi.fn((schema: unknown) => schema),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockCreateSimpleClient = vi.fn();
|
||||
|
||||
vi.mock('../../../client/factory', () => ({
|
||||
createSimpleClient: (...args: unknown[]) => mockCreateSimpleClient(...args),
|
||||
}));
|
||||
|
||||
const mockLoadPrompt = vi.fn();
|
||||
|
||||
vi.mock('../../../prompts/prompt-loader', () => ({
|
||||
loadPrompt: (...args: unknown[]) => mockLoadPrompt(...args),
|
||||
}));
|
||||
|
||||
const mockBuildToolRegistry = vi.fn();
|
||||
|
||||
vi.mock('../../../tools/build-registry', () => ({
|
||||
buildToolRegistry: (...args: unknown[]) => mockBuildToolRegistry(...args),
|
||||
}));
|
||||
|
||||
const mockGetSecurityProfile = vi.fn();
|
||||
|
||||
vi.mock('../../../security/security-profile', () => ({
|
||||
getSecurityProfile: (...args: unknown[]) => mockGetSecurityProfile(...args),
|
||||
}));
|
||||
|
||||
const mockGetAgentConfig = vi.fn();
|
||||
|
||||
vi.mock('../../../config/agent-configs', () => ({
|
||||
getAgentConfig: (...args: unknown[]) => mockGetAgentConfig(...args),
|
||||
}));
|
||||
|
||||
// =============================================================================
|
||||
// Import after mocking
|
||||
// =============================================================================
|
||||
|
||||
import {
|
||||
ParallelOrchestratorReviewer,
|
||||
MergeVerdict,
|
||||
type ParallelOrchestratorConfig,
|
||||
} from '../parallel-orchestrator';
|
||||
import { createMockPRContext } from '@shared/test-utils';
|
||||
import type { ProgressCallback } from '../pr-review-engine';
|
||||
|
||||
// =============================================================================
|
||||
// Test Constants & Helpers
|
||||
// =============================================================================
|
||||
|
||||
const mockModel = { modelId: 'claude-3-5-sonnet-20241022' };
|
||||
|
||||
function createMockClient(
|
||||
model = mockModel,
|
||||
systemPrompt = 'You are a helpful assistant.'
|
||||
): { model: typeof mockModel; systemPrompt: string } {
|
||||
return { model, systemPrompt };
|
||||
}
|
||||
|
||||
function createConfig(overrides: Partial<ParallelOrchestratorConfig> = {}): ParallelOrchestratorConfig {
|
||||
return {
|
||||
repo: 'test/repo',
|
||||
projectDir: '/tmp/test-project',
|
||||
model: 'sonnet',
|
||||
thinkingLevel: 'medium',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper for creating streamText mock results with structured output
|
||||
// Note: This differs from shared mockStreamText by including an 'output' field
|
||||
// for AI SDK structured output (Output.object()), which is required for testing
|
||||
// the parallel orchestrator's synthesis and validation phases.
|
||||
function createMockStreamResult(
|
||||
text: string,
|
||||
object: unknown = null,
|
||||
finishReason: 'stop' | 'length' | 'error' = 'stop'
|
||||
): {
|
||||
text: string;
|
||||
output: unknown;
|
||||
usage: { promptTokens: number; completionTokens: number; totalTokens: number };
|
||||
finishReason: 'stop' | 'length' | 'error';
|
||||
fullStream: AsyncIterable<unknown>;
|
||||
} {
|
||||
async function* generateFullStream() {
|
||||
yield { type: 'text-delta', textDelta: text };
|
||||
yield { type: 'finish', finishReason, usage: { promptTokens: 1000, completionTokens: 500 } };
|
||||
}
|
||||
|
||||
return {
|
||||
text,
|
||||
output: object,
|
||||
usage: { promptTokens: 1000, completionTokens: 500, totalTokens: 1500 },
|
||||
finishReason,
|
||||
fullStream: generateFullStream(),
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Tests: Constructor
|
||||
// =============================================================================
|
||||
|
||||
describe('ParallelOrchestratorReviewer - Constructor', () => {
|
||||
it('should create instance with config', () => {
|
||||
const config = createConfig();
|
||||
const reviewer = new ParallelOrchestratorReviewer(config);
|
||||
|
||||
expect(reviewer).toBeInstanceOf(ParallelOrchestratorReviewer);
|
||||
});
|
||||
|
||||
it('should accept optional progress callback', () => {
|
||||
const progressCallback = vi.fn() as unknown as ProgressCallback;
|
||||
const config = createConfig();
|
||||
const reviewer = new ParallelOrchestratorReviewer(config, progressCallback);
|
||||
|
||||
expect(reviewer).toBeInstanceOf(ParallelOrchestratorReviewer);
|
||||
});
|
||||
|
||||
it('should create instance without progress callback', () => {
|
||||
const config = createConfig();
|
||||
const reviewer = new ParallelOrchestratorReviewer(config);
|
||||
|
||||
expect(reviewer).toBeInstanceOf(ParallelOrchestratorReviewer);
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Tests: review() - Happy Path
|
||||
// =============================================================================
|
||||
|
||||
describe('ParallelOrchestratorReviewer - review() Happy Path', () => {
|
||||
let progressCallback: ProgressCallback;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
progressCallback = vi.fn() as unknown as ProgressCallback;
|
||||
|
||||
// Setup default mocks
|
||||
mockLoadPrompt.mockResolvedValue('You are a specialist reviewer.');
|
||||
mockCreateSimpleClient.mockReturnValue(createMockClient());
|
||||
mockBuildToolRegistry.mockReturnValue({ getToolsForAgent: vi.fn(() => ({})) });
|
||||
mockGetSecurityProfile.mockReturnValue({
|
||||
allowedCommands: [],
|
||||
allowedPaths: [],
|
||||
});
|
||||
mockGetAgentConfig.mockReturnValue({
|
||||
agentType: 'pr_specialist',
|
||||
model: 'sonnet',
|
||||
systemPrompt: 'You are a specialist.',
|
||||
tools: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should run all 4 specialists in parallel', async () => {
|
||||
const reviewer = new ParallelOrchestratorReviewer(createConfig(), progressCallback);
|
||||
const context = createMockPRContext({ prNumber: 42 });
|
||||
|
||||
// Mock all specialists to return no findings (simplest case)
|
||||
for (let i = 0; i < 4; i++) {
|
||||
mockStreamText.mockResolvedValueOnce(
|
||||
createMockStreamResult('Complete', { findings: [] })
|
||||
);
|
||||
}
|
||||
|
||||
const result = await reviewer.review(context);
|
||||
|
||||
// Should call 4 specialists (no synthesis when no findings)
|
||||
expect(mockStreamText).toHaveBeenCalledTimes(4);
|
||||
expect(result.agentsInvoked).toEqual(['security', 'quality', 'logic', 'codebase-fit']);
|
||||
expect(result.verdict).toBe(MergeVerdict.READY_TO_MERGE);
|
||||
});
|
||||
|
||||
it('should synthesize verdict correctly - READY_TO_MERGE with no findings', async () => {
|
||||
const reviewer = new ParallelOrchestratorReviewer(createConfig(), progressCallback);
|
||||
const context = createMockPRContext({ prNumber: 42 });
|
||||
|
||||
// Mock all specialists to return no findings
|
||||
for (let i = 0; i < 4; i++) {
|
||||
mockStreamText.mockResolvedValueOnce(
|
||||
createMockStreamResult('No issues', { findings: [] })
|
||||
);
|
||||
}
|
||||
|
||||
const result = await reviewer.review(context);
|
||||
|
||||
// When no findings, synthesis returns early with default verdict
|
||||
expect(result.verdict).toBe(MergeVerdict.READY_TO_MERGE);
|
||||
expect(result.verdictReasoning).toBe('No issues found by any specialist reviewer.');
|
||||
expect(result.blockers).toEqual([]);
|
||||
expect(result.findings).toEqual([]);
|
||||
});
|
||||
|
||||
it('should call progress callback for each phase', async () => {
|
||||
const reviewer = new ParallelOrchestratorReviewer(createConfig(), progressCallback);
|
||||
const context = createMockPRContext({ prNumber: 42 });
|
||||
|
||||
// Mock all specialists to return no findings
|
||||
for (let i = 0; i < 4; i++) {
|
||||
mockStreamText.mockResolvedValueOnce(
|
||||
createMockStreamResult('Complete', { findings: [] })
|
||||
);
|
||||
}
|
||||
|
||||
await reviewer.review(context);
|
||||
|
||||
// Should have progress updates
|
||||
expect(progressCallback).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle findings from specialists', async () => {
|
||||
const reviewer = new ParallelOrchestratorReviewer(createConfig(), progressCallback);
|
||||
const context = createMockPRContext({ prNumber: 42 });
|
||||
|
||||
// Mock all specialists to return no findings except one
|
||||
mockStreamText
|
||||
.mockResolvedValueOnce(createMockStreamResult('Complete', {
|
||||
findings: [{ title: 'Security issue', file: 'src/test.ts', line: 10, severity: 'high', category: 'security' }],
|
||||
}))
|
||||
.mockResolvedValueOnce(createMockStreamResult('Complete', { findings: [] }))
|
||||
.mockResolvedValueOnce(createMockStreamResult('Complete', { findings: [] }))
|
||||
.mockResolvedValueOnce(createMockStreamResult('Complete', { findings: [] }));
|
||||
|
||||
const result = await reviewer.review(context);
|
||||
|
||||
// Should call 4 specialists
|
||||
expect(mockStreamText).toHaveBeenCalledTimes(4);
|
||||
expect(result.agentsInvoked).toEqual(['security', 'quality', 'logic', 'codebase-fit']);
|
||||
});
|
||||
|
||||
// Note: Cross-validation tests require integration-level testing due to:
|
||||
// - MD5-based finding ID generation (file:line:title hash)
|
||||
// - Synthesis phase requires exact finding ID matching in kept_finding_ids
|
||||
// - Cross-validation groups by file:lineGroup(5):category
|
||||
// These are tested in integration tests with actual AI SDK responses
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Tests: Abort Signal Handling
|
||||
// =============================================================================
|
||||
|
||||
describe('ParallelOrchestratorReviewer - Abort Signal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockLoadPrompt.mockResolvedValue('You are a specialist reviewer.');
|
||||
mockCreateSimpleClient.mockReturnValue(createMockClient());
|
||||
mockBuildToolRegistry.mockReturnValue({ getToolsForAgent: vi.fn(() => ({})) });
|
||||
mockGetSecurityProfile.mockReturnValue({
|
||||
allowedCommands: [],
|
||||
allowedPaths: [],
|
||||
});
|
||||
mockGetAgentConfig.mockReturnValue({
|
||||
agentType: 'pr_specialist',
|
||||
model: 'sonnet',
|
||||
systemPrompt: 'You are a specialist.',
|
||||
tools: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle abort signal during specialist execution', async () => {
|
||||
const progressCallback = vi.fn() as unknown as ProgressCallback;
|
||||
const reviewer = new ParallelOrchestratorReviewer(createConfig(), progressCallback);
|
||||
const context = createMockPRContext({ prNumber: 42 });
|
||||
|
||||
const abortController = new AbortController();
|
||||
abortController.abort();
|
||||
|
||||
// Mock streamText to check for abort
|
||||
mockStreamText.mockImplementation(async ({ abortSignal }: { abortSignal?: AbortSignal }) => {
|
||||
if (abortSignal?.aborted) {
|
||||
throw new Error('Aborted');
|
||||
}
|
||||
return createMockStreamResult('Complete', { findings: [] });
|
||||
});
|
||||
|
||||
// Should handle abort gracefully
|
||||
const result = await reviewer.review(context, abortController.signal);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.findings).toEqual([]);
|
||||
});
|
||||
|
||||
it('should stop processing when abort signal is received mid-execution', async () => {
|
||||
const progressCallback = vi.fn() as unknown as ProgressCallback;
|
||||
const reviewer = new ParallelOrchestratorReviewer(createConfig(), progressCallback);
|
||||
const context = createMockPRContext({ prNumber: 42 });
|
||||
|
||||
const abortController = new AbortController();
|
||||
|
||||
// Abort after first call
|
||||
let callCount = 0;
|
||||
mockStreamText.mockImplementation(async ({ abortSignal }: { abortSignal?: AbortSignal }) => {
|
||||
callCount++;
|
||||
if (callCount > 2) {
|
||||
abortController.abort();
|
||||
}
|
||||
if (abortSignal?.aborted) {
|
||||
return createMockStreamResult('Aborted', { findings: [] });
|
||||
}
|
||||
return createMockStreamResult('Complete', { findings: [] });
|
||||
});
|
||||
|
||||
const result = await reviewer.review(context, abortController.signal);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Tests: Specialist Failure Handling
|
||||
// =============================================================================
|
||||
|
||||
describe('ParallelOrchestratorReviewer - Specialist Failures', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockLoadPrompt.mockResolvedValue('You are a specialist reviewer.');
|
||||
mockCreateSimpleClient.mockReturnValue(createMockClient());
|
||||
mockBuildToolRegistry.mockReturnValue({ getToolsForAgent: vi.fn(() => ({})) });
|
||||
mockGetSecurityProfile.mockReturnValue({
|
||||
allowedCommands: [],
|
||||
allowedPaths: [],
|
||||
});
|
||||
mockGetAgentConfig.mockReturnValue({
|
||||
agentType: 'pr_specialist',
|
||||
model: 'sonnet',
|
||||
systemPrompt: 'You are a specialist.',
|
||||
tools: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle specialist failures with Promise.allSettled', async () => {
|
||||
const progressCallback = vi.fn() as unknown as ProgressCallback;
|
||||
const reviewer = new ParallelOrchestratorReviewer(createConfig(), progressCallback);
|
||||
const context = createMockPRContext({ prNumber: 42 });
|
||||
|
||||
// Mock 2 specialists to succeed, 2 to fail
|
||||
mockStreamText
|
||||
.mockResolvedValueOnce(createMockStreamResult('Success', { findings: [] }))
|
||||
.mockRejectedValueOnce(new Error('Specialist failed'))
|
||||
.mockResolvedValueOnce(createMockStreamResult('Success', { findings: [] }))
|
||||
.mockRejectedValueOnce(new Error('Another specialist failed'));
|
||||
|
||||
// Mock synthesis (no findings from successful specialists)
|
||||
mockStreamText.mockResolvedValueOnce(
|
||||
createMockStreamResult('Complete', {
|
||||
verdict: 'ready_to_merge',
|
||||
verdictReasoning: 'Partial success',
|
||||
kept_finding_ids: [],
|
||||
removed_finding_ids: [],
|
||||
removal_reasons: {},
|
||||
})
|
||||
);
|
||||
|
||||
const result = await reviewer.review(context);
|
||||
|
||||
// Should complete with partial results
|
||||
expect(result).toBeDefined();
|
||||
expect(result.agentsInvoked).toEqual(['security', 'quality', 'logic', 'codebase-fit']);
|
||||
});
|
||||
|
||||
it('should continue with remaining specialists when one fails', async () => {
|
||||
const progressCallback = vi.fn() as unknown as ProgressCallback;
|
||||
const reviewer = new ParallelOrchestratorReviewer(createConfig(), progressCallback);
|
||||
const context = createMockPRContext({ prNumber: 42 });
|
||||
|
||||
// Mock first specialist to fail, others to succeed
|
||||
mockStreamText
|
||||
.mockRejectedValueOnce(new Error('Network error'))
|
||||
.mockResolvedValueOnce(createMockStreamResult('Success', { findings: [] }))
|
||||
.mockResolvedValueOnce(createMockStreamResult('Success', { findings: [] }))
|
||||
.mockResolvedValueOnce(createMockStreamResult('Success', { findings: [] }));
|
||||
|
||||
// Mock synthesis
|
||||
mockStreamText.mockResolvedValueOnce(
|
||||
createMockStreamResult('Complete', {
|
||||
verdict: 'ready_to_merge',
|
||||
verdictReasoning: 'Completed with partial results',
|
||||
kept_finding_ids: [],
|
||||
removed_finding_ids: [],
|
||||
removal_reasons: {},
|
||||
})
|
||||
);
|
||||
|
||||
const result = await reviewer.review(context);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.agentsInvoked.length).toBe(4);
|
||||
});
|
||||
|
||||
it('should handle all specialists failing', async () => {
|
||||
const progressCallback = vi.fn() as unknown as ProgressCallback;
|
||||
const reviewer = new ParallelOrchestratorReviewer(createConfig(), progressCallback);
|
||||
const context = createMockPRContext({ prNumber: 42 });
|
||||
|
||||
// Mock all specialists to fail
|
||||
for (let i = 0; i < 4; i++) {
|
||||
mockStreamText.mockRejectedValueOnce(new Error('All failed'));
|
||||
}
|
||||
|
||||
// Mock synthesis to handle empty findings
|
||||
mockStreamText.mockResolvedValueOnce(
|
||||
createMockStreamResult('No findings', {
|
||||
verdict: 'ready_to_merge',
|
||||
verdictReasoning: 'No specialists returned findings',
|
||||
kept_finding_ids: [],
|
||||
removed_finding_ids: [],
|
||||
removal_reasons: {},
|
||||
})
|
||||
);
|
||||
|
||||
const result = await reviewer.review(context);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.findings).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Tests: Edge Cases
|
||||
// =============================================================================
|
||||
|
||||
describe('ParallelOrchestratorReviewer - Edge Cases', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockLoadPrompt.mockResolvedValue('You are a specialist reviewer.');
|
||||
mockCreateSimpleClient.mockReturnValue(createMockClient());
|
||||
mockBuildToolRegistry.mockReturnValue({ getToolsForAgent: vi.fn(() => ({})) });
|
||||
mockGetSecurityProfile.mockReturnValue({
|
||||
allowedCommands: [],
|
||||
allowedPaths: [],
|
||||
});
|
||||
mockGetAgentConfig.mockReturnValue({
|
||||
agentType: 'pr_specialist',
|
||||
model: 'sonnet',
|
||||
systemPrompt: 'You are a specialist.',
|
||||
tools: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty findings from all specialists', async () => {
|
||||
const progressCallback = vi.fn() as unknown as ProgressCallback;
|
||||
const reviewer = new ParallelOrchestratorReviewer(createConfig(), progressCallback);
|
||||
const context = createMockPRContext({ prNumber: 42 });
|
||||
|
||||
// Mock all specialists to return empty findings
|
||||
for (let i = 0; i < 4; i++) {
|
||||
mockStreamText.mockResolvedValueOnce(
|
||||
createMockStreamResult('No issues', { findings: [] })
|
||||
);
|
||||
}
|
||||
|
||||
const result = await reviewer.review(context);
|
||||
|
||||
expect(result.findings).toEqual([]);
|
||||
expect(result.verdict).toBe(MergeVerdict.READY_TO_MERGE);
|
||||
expect(result.blockers).toEqual([]);
|
||||
});
|
||||
|
||||
// Note: Testing synthesis failure requires complex mock setup due to MD5-based finding IDs
|
||||
// This is tested in integration tests instead
|
||||
|
||||
|
||||
it('should handle complex PR with many files', async () => {
|
||||
const progressCallback = vi.fn() as unknown as ProgressCallback;
|
||||
const reviewer = new ParallelOrchestratorReviewer(createConfig(), progressCallback);
|
||||
|
||||
const context = createMockPRContext({
|
||||
prNumber: 42,
|
||||
changedFiles: Array.from({ length: 50 }, (_, i) => ({
|
||||
path: `src/file${i}.ts`,
|
||||
additions: 10,
|
||||
deletions: 5,
|
||||
status: 'modified',
|
||||
patch: `@@ -1,1 +1,2 @@\n-line\n+line`,
|
||||
})),
|
||||
totalAdditions: 500,
|
||||
totalDeletions: 250,
|
||||
});
|
||||
|
||||
// Mock specialists
|
||||
for (let i = 0; i < 4; i++) {
|
||||
mockStreamText.mockResolvedValueOnce(
|
||||
createMockStreamResult('Review complete', { findings: [] })
|
||||
);
|
||||
}
|
||||
|
||||
const result = await reviewer.review(context);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.verdict).toBe(MergeVerdict.READY_TO_MERGE);
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Tests: Result Structure
|
||||
// =============================================================================
|
||||
|
||||
describe('ParallelOrchestratorReviewer - Result Structure', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockLoadPrompt.mockResolvedValue('You are a specialist reviewer.');
|
||||
mockCreateSimpleClient.mockReturnValue(createMockClient());
|
||||
mockBuildToolRegistry.mockReturnValue({ getToolsForAgent: vi.fn(() => ({})) });
|
||||
mockGetSecurityProfile.mockReturnValue({
|
||||
allowedCommands: [],
|
||||
allowedPaths: [],
|
||||
});
|
||||
mockGetAgentConfig.mockReturnValue({
|
||||
agentType: 'pr_specialist',
|
||||
model: 'sonnet',
|
||||
systemPrompt: 'You are a specialist.',
|
||||
tools: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should return result with all required fields', async () => {
|
||||
const progressCallback = vi.fn() as unknown as ProgressCallback;
|
||||
const reviewer = new ParallelOrchestratorReviewer(createConfig(), progressCallback);
|
||||
const context = createMockPRContext({ prNumber: 42 });
|
||||
|
||||
// Mock specialists
|
||||
for (let i = 0; i < 4; i++) {
|
||||
mockStreamText.mockResolvedValueOnce(
|
||||
createMockStreamResult('Complete', { findings: [] })
|
||||
);
|
||||
}
|
||||
|
||||
const result = await reviewer.review(context);
|
||||
|
||||
expect(result).toHaveProperty('findings');
|
||||
expect(result).toHaveProperty('verdict');
|
||||
expect(result).toHaveProperty('verdictReasoning');
|
||||
expect(result).toHaveProperty('summary');
|
||||
expect(result).toHaveProperty('blockers');
|
||||
expect(result).toHaveProperty('agentsInvoked');
|
||||
expect(Array.isArray(result.findings)).toBe(true);
|
||||
expect(Array.isArray(result.blockers)).toBe(true);
|
||||
expect(Array.isArray(result.agentsInvoked)).toBe(true);
|
||||
expect(typeof result.verdict).toBe('string');
|
||||
expect(typeof result.verdictReasoning).toBe('string');
|
||||
expect(typeof result.summary).toBe('string');
|
||||
});
|
||||
|
||||
it('should include summary with verdict', async () => {
|
||||
const progressCallback = vi.fn() as unknown as ProgressCallback;
|
||||
const reviewer = new ParallelOrchestratorReviewer(createConfig(), progressCallback);
|
||||
const context = createMockPRContext({ prNumber: 42 });
|
||||
|
||||
// Mock specialists
|
||||
for (let i = 0; i < 4; i++) {
|
||||
mockStreamText.mockResolvedValueOnce(
|
||||
createMockStreamResult('Complete', { findings: [] })
|
||||
);
|
||||
}
|
||||
|
||||
const result = await reviewer.review(context);
|
||||
|
||||
expect(result.summary).toContain('Review:');
|
||||
expect(result.summary).toContain('Ready To Merge');
|
||||
expect(result.summary).toContain('✅');
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,23 +1,7 @@
|
||||
/**
|
||||
* Codex Authentication IPC Handlers
|
||||
*
|
||||
* IPC handlers for Codex OAuth authentication:
|
||||
* - codex-auth-login - Start OAuth flow to authenticate with Codex
|
||||
* - codex-auth-status - Get current authentication status
|
||||
* - codex-auth-logout - Clear authentication and logout
|
||||
*/
|
||||
|
||||
import { ipcMain } from 'electron';
|
||||
import { startCodexOAuthFlow, getCodexAuthState, clearCodexAuth } from '../ai/auth/codex-oauth';
|
||||
|
||||
/**
|
||||
* Register all Codex authentication-related IPC handlers
|
||||
*/
|
||||
export function registerCodexAuthHandlers(): void {
|
||||
/**
|
||||
* Start Codex OAuth login flow
|
||||
* Opens browser for user authentication and returns the auth result
|
||||
*/
|
||||
ipcMain.handle('codex-auth-login', async () => {
|
||||
try {
|
||||
const result = await startCodexOAuthFlow();
|
||||
@@ -27,10 +11,6 @@ export function registerCodexAuthHandlers(): void {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get current Codex authentication status
|
||||
* Returns the current auth state including tokens and user info
|
||||
*/
|
||||
ipcMain.handle('codex-auth-status', async () => {
|
||||
try {
|
||||
const state = await getCodexAuthState();
|
||||
@@ -40,10 +20,6 @@ export function registerCodexAuthHandlers(): void {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Logout from Codex and clear authentication
|
||||
* Removes stored tokens and clears the auth state
|
||||
*/
|
||||
ipcMain.handle('codex-auth-logout', async () => {
|
||||
try {
|
||||
await clearCodexAuth();
|
||||
|
||||
@@ -1,641 +0,0 @@
|
||||
/**
|
||||
* Unit tests for GitHub API utility functions
|
||||
* Tests githubFetch() error handling, header configuration, and response parsing
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { githubFetch, githubFetchWithRetry, validateGitHubToken } from '../utils';
|
||||
|
||||
// Mock fetch globally
|
||||
const mockFetch = vi.fn();
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
describe('githubFetch', () => {
|
||||
beforeEach(() => {
|
||||
mockFetch.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
describe('successful requests', () => {
|
||||
it('should successfully fetch data from GitHub API with proper headers', async () => {
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ data: 'test', id: 123 }),
|
||||
headers: new Headers()
|
||||
};
|
||||
mockFetch.mockResolvedValueOnce(mockResponse);
|
||||
|
||||
const result = await githubFetch('test-token', '/repos/test/repo/issues');
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://api.github.com/repos/test/repo/issues',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
'Accept': 'application/vnd.github+json',
|
||||
'Authorization': 'Bearer test-token',
|
||||
'User-Agent': 'Aperant'
|
||||
})
|
||||
})
|
||||
);
|
||||
expect(result).toEqual({ data: 'test', id: 123 });
|
||||
});
|
||||
|
||||
it('should handle full URLs without prefixing', async () => {
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ result: 'success' }),
|
||||
headers: new Headers()
|
||||
};
|
||||
mockFetch.mockResolvedValueOnce(mockResponse);
|
||||
|
||||
await githubFetch('test-token', 'https://api.github.com/users/test');
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://api.github.com/users/test',
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass through custom options', async () => {
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ data: 'test' }),
|
||||
headers: new Headers()
|
||||
};
|
||||
mockFetch.mockResolvedValueOnce(mockResponse);
|
||||
|
||||
await githubFetch('test-token', '/test', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ test: 'data' })
|
||||
});
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://api.github.com/test',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ test: 'data' })
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle empty token safely', async () => {
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ data: 'test' }),
|
||||
headers: new Headers()
|
||||
};
|
||||
mockFetch.mockResolvedValueOnce(mockResponse);
|
||||
|
||||
await githubFetch('', '/test');
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://api.github.com/test',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
'Authorization': 'Bearer '
|
||||
})
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should throw detailed error for 500 Internal Server Error', async () => {
|
||||
const mockResponse = {
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: async () => 'Internal Server Error: Database connection failed',
|
||||
headers: new Headers()
|
||||
};
|
||||
mockFetch.mockResolvedValueOnce(mockResponse);
|
||||
|
||||
await expect(
|
||||
githubFetch('test-token', '/repos/test/repo/issues')
|
||||
).rejects.toThrow('GitHub API error (500): Internal Server Error: Database connection failed');
|
||||
});
|
||||
|
||||
it('should throw detailed error for 401 Unauthorized', async () => {
|
||||
const mockResponse = {
|
||||
ok: false,
|
||||
status: 401,
|
||||
text: async () => '{"message": "Bad credentials", "documentation_url": "https://docs.github.com/rest"}',
|
||||
headers: new Headers()
|
||||
};
|
||||
mockFetch.mockResolvedValueOnce(mockResponse);
|
||||
|
||||
await expect(
|
||||
githubFetch('invalid-token', '/repos/test/repo/issues')
|
||||
).rejects.toThrow('GitHub API error (401): Bad credentials');
|
||||
});
|
||||
|
||||
it('should throw detailed error for 404 Not Found', async () => {
|
||||
const mockResponse = {
|
||||
ok: false,
|
||||
status: 404,
|
||||
text: async () => '{"message": "Not Found", "documentation_url": "https://docs.github.com/rest"}',
|
||||
headers: new Headers()
|
||||
};
|
||||
mockFetch.mockResolvedValueOnce(mockResponse);
|
||||
|
||||
await expect(
|
||||
githubFetch('test-token', '/repos/nonexistent/repo/issues')
|
||||
).rejects.toThrow('GitHub API error (404): Not Found');
|
||||
});
|
||||
|
||||
it('should throw detailed error for 429 Rate Limit', async () => {
|
||||
const mockResponse = {
|
||||
ok: false,
|
||||
status: 429,
|
||||
text: async () => '{"message": "API rate limit exceeded for user ID 123", "documentation_url": "https://docs.github.com/rest"}',
|
||||
headers: new Headers()
|
||||
};
|
||||
mockFetch.mockResolvedValueOnce(mockResponse);
|
||||
|
||||
await expect(
|
||||
githubFetch('test-token', '/repos/test/repo/issues')
|
||||
).rejects.toThrow('GitHub API error (429): API rate limit exceeded for user ID 123');
|
||||
});
|
||||
|
||||
it('should handle empty response body in error case', async () => {
|
||||
const mockResponse = {
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: async () => '',
|
||||
headers: new Headers()
|
||||
};
|
||||
mockFetch.mockResolvedValueOnce(mockResponse);
|
||||
|
||||
await expect(
|
||||
githubFetch('test-token', '/repos/test/repo/issues')
|
||||
).rejects.toThrow('GitHub API error (500):');
|
||||
});
|
||||
|
||||
it('should handle failed text() parsing with fallback message', async () => {
|
||||
const mockResponse = {
|
||||
ok: false,
|
||||
status: 503,
|
||||
text: async () => {
|
||||
throw new Error('Network error');
|
||||
},
|
||||
headers: new Headers()
|
||||
};
|
||||
mockFetch.mockResolvedValueOnce(mockResponse);
|
||||
|
||||
await expect(
|
||||
githubFetch('test-token', '/repos/test/repo/issues')
|
||||
).rejects.toThrow('GitHub API error (503): Request failed');
|
||||
});
|
||||
|
||||
it('should include User-Agent header in error requests', async () => {
|
||||
const mockResponse = {
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: async () => 'Server Error',
|
||||
headers: new Headers()
|
||||
};
|
||||
mockFetch.mockResolvedValueOnce(mockResponse);
|
||||
|
||||
try {
|
||||
await githubFetch('test-token', '/test');
|
||||
} catch (e) {
|
||||
// Expected to throw
|
||||
}
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
'User-Agent': 'Aperant'
|
||||
})
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle HTML error responses', async () => {
|
||||
const mockResponse = {
|
||||
ok: false,
|
||||
status: 502,
|
||||
text: async () => '<html><body>Bad Gateway</body></html>',
|
||||
headers: new Headers()
|
||||
};
|
||||
mockFetch.mockResolvedValueOnce(mockResponse);
|
||||
|
||||
await expect(
|
||||
githubFetch('test-token', '/repos/test/repo/issues')
|
||||
).rejects.toThrow('GitHub API error (502): <html><body>Bad Gateway</body></html>');
|
||||
});
|
||||
|
||||
it('should handle JSON error responses', async () => {
|
||||
const mockResponse = {
|
||||
ok: false,
|
||||
status: 422,
|
||||
text: async () => '{"message": "Validation Failed", "errors": [{"resource": "Issue", "field": "title", "code": "missing"}]}',
|
||||
headers: new Headers()
|
||||
};
|
||||
mockFetch.mockResolvedValueOnce(mockResponse);
|
||||
|
||||
await expect(
|
||||
githubFetch('test-token', '/repos/test/repo/issues')
|
||||
).rejects.toThrow('GitHub API error (422): Validation Failed');
|
||||
});
|
||||
|
||||
it('should preserve error details with status and body', async () => {
|
||||
const errorBody = '{"message": "Repository access denied", "documentation_url": "https://docs.github.com/rest"}';
|
||||
const mockResponse = {
|
||||
ok: false,
|
||||
status: 403,
|
||||
text: async () => errorBody,
|
||||
headers: new Headers()
|
||||
};
|
||||
mockFetch.mockResolvedValueOnce(mockResponse);
|
||||
|
||||
try {
|
||||
await githubFetch('test-token', '/repos/private/repo/issues');
|
||||
expect.fail('Should have thrown an error');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect((error as Error).message).toContain('403');
|
||||
expect((error as Error).message).toContain('Repository access denied');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle response with no body text', async () => {
|
||||
const mockResponse = {
|
||||
ok: false,
|
||||
status: 204,
|
||||
text: async () => '',
|
||||
headers: new Headers()
|
||||
};
|
||||
mockFetch.mockResolvedValueOnce(mockResponse);
|
||||
|
||||
await expect(
|
||||
githubFetch('test-token', '/test')
|
||||
).rejects.toThrow('GitHub API error (204):');
|
||||
});
|
||||
|
||||
it('should handle null token', async () => {
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ data: 'test' }),
|
||||
headers: new Headers()
|
||||
};
|
||||
mockFetch.mockResolvedValueOnce(mockResponse);
|
||||
|
||||
await githubFetch(null as unknown as string, '/test');
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://api.github.com/test',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
'Authorization': 'Bearer '
|
||||
})
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle undefined token', async () => {
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ data: 'test' }),
|
||||
headers: new Headers()
|
||||
};
|
||||
mockFetch.mockResolvedValueOnce(mockResponse);
|
||||
|
||||
await githubFetch(undefined as unknown as string, '/test');
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://api.github.com/test',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
'Authorization': 'Bearer '
|
||||
})
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('githubFetchWithRetry', () => {
|
||||
beforeEach(() => {
|
||||
mockFetch.mockClear();
|
||||
});
|
||||
|
||||
it('should retry on 500 errors with exponential backoff', async () => {
|
||||
const mockResponse = {
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: async () => 'Internal Server Error',
|
||||
headers: new Headers()
|
||||
};
|
||||
const mockSuccess = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ data: 'success' }),
|
||||
headers: new Headers()
|
||||
};
|
||||
|
||||
mockFetch.mockResolvedValueOnce(mockResponse)
|
||||
.mockResolvedValueOnce(mockResponse)
|
||||
.mockResolvedValueOnce(mockSuccess);
|
||||
|
||||
const result = await githubFetchWithRetry('test-token', '/test');
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(3);
|
||||
expect(result).toEqual({ data: 'success' });
|
||||
}, 10000);
|
||||
|
||||
it('should NOT retry on 401 Unauthorized', async () => {
|
||||
const mockResponse = {
|
||||
ok: false,
|
||||
status: 401,
|
||||
text: async () => '{"message": "Bad credentials"}',
|
||||
headers: new Headers()
|
||||
};
|
||||
mockFetch.mockResolvedValueOnce(mockResponse);
|
||||
|
||||
await expect(
|
||||
githubFetchWithRetry('test-token', '/test')
|
||||
).rejects.toThrow('GitHub API error (401): Bad credentials');
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should NOT retry on 404 Not Found', async () => {
|
||||
const mockResponse = {
|
||||
ok: false,
|
||||
status: 404,
|
||||
text: async () => '{"message": "Not Found"}',
|
||||
headers: new Headers()
|
||||
};
|
||||
mockFetch.mockResolvedValueOnce(mockResponse);
|
||||
|
||||
await expect(
|
||||
githubFetchWithRetry('test-token', '/test')
|
||||
).rejects.toThrow('GitHub API error (404): Not Found');
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should NOT retry on 429 Rate Limit', async () => {
|
||||
const mockResponse = {
|
||||
ok: false,
|
||||
status: 429,
|
||||
text: async () => '{"message": "API rate limit exceeded"}',
|
||||
headers: new Headers()
|
||||
};
|
||||
mockFetch.mockResolvedValueOnce(mockResponse);
|
||||
|
||||
await expect(
|
||||
githubFetchWithRetry('test-token', '/test')
|
||||
).rejects.toThrow('GitHub API error (429): API rate limit exceeded');
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should respect custom maxRetries', async () => {
|
||||
const mockResponse = {
|
||||
ok: false,
|
||||
status: 503,
|
||||
text: async () => 'Service Unavailable',
|
||||
headers: new Headers()
|
||||
};
|
||||
mockFetch.mockResolvedValue(mockResponse);
|
||||
|
||||
await expect(
|
||||
githubFetchWithRetry('test-token', '/test', {}, 1)
|
||||
).rejects.toThrow();
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should retry on 502 Bad Gateway', async () => {
|
||||
const mockResponse = {
|
||||
ok: false,
|
||||
status: 502,
|
||||
text: async () => 'Bad Gateway',
|
||||
headers: new Headers()
|
||||
};
|
||||
const mockSuccess = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ data: 'success' }),
|
||||
headers: new Headers()
|
||||
};
|
||||
|
||||
mockFetch.mockResolvedValueOnce(mockResponse)
|
||||
.mockResolvedValueOnce(mockSuccess);
|
||||
|
||||
const result = await githubFetchWithRetry('test-token', '/test');
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
expect(result).toEqual({ data: 'success' });
|
||||
});
|
||||
|
||||
it('should retry on 503 Service Unavailable', async () => {
|
||||
const mockResponse = {
|
||||
ok: false,
|
||||
status: 503,
|
||||
text: async () => 'Service Unavailable',
|
||||
headers: new Headers()
|
||||
};
|
||||
const mockSuccess = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ data: 'success' }),
|
||||
headers: new Headers()
|
||||
};
|
||||
|
||||
mockFetch.mockResolvedValueOnce(mockResponse)
|
||||
.mockResolvedValueOnce(mockSuccess);
|
||||
|
||||
const result = await githubFetchWithRetry('test-token', '/test');
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
expect(result).toEqual({ data: 'success' });
|
||||
});
|
||||
|
||||
it('should throw after max retries exhausted', async () => {
|
||||
const mockResponse = {
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: async () => 'Internal Server Error',
|
||||
headers: new Headers()
|
||||
};
|
||||
mockFetch.mockResolvedValue(mockResponse);
|
||||
|
||||
await expect(
|
||||
githubFetchWithRetry('test-token', '/test', {}, 2)
|
||||
).rejects.toThrow('GitHub API error (500): Internal Server Error');
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('should pass custom options to githubFetch', async () => {
|
||||
const mockSuccess = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ data: 'success' }),
|
||||
headers: new Headers()
|
||||
};
|
||||
mockFetch.mockResolvedValueOnce(mockSuccess);
|
||||
|
||||
const result = await githubFetchWithRetry('test-token', '/test', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ test: 'data' })
|
||||
});
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://api.github.com/test',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ test: 'data' })
|
||||
})
|
||||
);
|
||||
expect(result).toEqual({ data: 'success' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateGitHubToken', () => {
|
||||
beforeEach(() => {
|
||||
mockFetch.mockClear();
|
||||
});
|
||||
|
||||
it('should return valid: true for good token', async () => {
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ login: 'testuser' }),
|
||||
};
|
||||
mockFetch.mockResolvedValueOnce(mockResponse);
|
||||
|
||||
const result = await validateGitHubToken('good-token');
|
||||
|
||||
expect(result).toEqual({ valid: true });
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://api.github.com/user',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
'Authorization': 'Bearer good-token',
|
||||
'User-Agent': 'Aperant'
|
||||
})
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should return valid: false with error message for 401', async () => {
|
||||
const mockResponse = {
|
||||
ok: false,
|
||||
status: 401,
|
||||
text: async () => '{"message": "Bad credentials"}',
|
||||
};
|
||||
mockFetch.mockResolvedValueOnce(mockResponse);
|
||||
|
||||
const result = await validateGitHubToken('bad-token');
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain('401');
|
||||
expect(result.error).toContain('Bad credentials');
|
||||
});
|
||||
|
||||
it('should return valid: false for 403 Forbidden', async () => {
|
||||
const mockResponse = {
|
||||
ok: false,
|
||||
status: 403,
|
||||
text: async () => '{"message": "Forbidden"}',
|
||||
};
|
||||
mockFetch.mockResolvedValueOnce(mockResponse);
|
||||
|
||||
const result = await validateGitHubToken('expired-token');
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain('403');
|
||||
});
|
||||
|
||||
it('should handle network errors', async () => {
|
||||
// Mock all retry attempts (4 total: initial + 3 retries) since improved error classification now detects this as retryable
|
||||
for (let i = 0; i < 4; i++) {
|
||||
mockFetch.mockRejectedValueOnce(new Error('Network error'));
|
||||
}
|
||||
|
||||
const result = await validateGitHubToken('test-token');
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain('Network error');
|
||||
}, 10000); // Increase timeout to accommodate retry delays (1s + 2s + 4s = 7s total)
|
||||
|
||||
it('should handle empty token safely', async () => {
|
||||
const result = await validateGitHubToken('');
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toBe('Token is empty');
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should mark 5xx errors as retryable', async () => {
|
||||
const mockResponse = {
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: async () => 'Internal Server Error',
|
||||
};
|
||||
// Provide 4 mocks for retry attempts (0, 1, 2, 3)
|
||||
mockFetch.mockResolvedValueOnce(mockResponse);
|
||||
mockFetch.mockResolvedValueOnce(mockResponse);
|
||||
mockFetch.mockResolvedValueOnce(mockResponse);
|
||||
mockFetch.mockResolvedValueOnce(mockResponse);
|
||||
|
||||
const result = await validateGitHubToken('test-token');
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain('500');
|
||||
expect(result.retryable).toBe(true);
|
||||
}, 10000); // 10s timeout to accommodate retry delays
|
||||
|
||||
it('should handle failed text() parsing with fallback', async () => {
|
||||
// Create fresh mock objects for each retry attempt
|
||||
const createMockResponse = () => ({
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: async () => {
|
||||
throw new Error('Parse error');
|
||||
},
|
||||
});
|
||||
// Provide 4 mocks for retry attempts (0, 1, 2, 3)
|
||||
mockFetch.mockResolvedValueOnce(createMockResponse());
|
||||
mockFetch.mockResolvedValueOnce(createMockResponse());
|
||||
mockFetch.mockResolvedValueOnce(createMockResponse());
|
||||
mockFetch.mockResolvedValueOnce(createMockResponse());
|
||||
|
||||
const result = await validateGitHubToken('test-token');
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain('Unknown error');
|
||||
expect(result.retryable).toBe(true);
|
||||
}, 10000); // 10s timeout to accommodate retry delays
|
||||
|
||||
it('should mark 401/403 as non-retryable auth failures', async () => {
|
||||
const mockResponse = {
|
||||
ok: false,
|
||||
status: 401,
|
||||
text: async () => 'Bad credentials',
|
||||
};
|
||||
mockFetch.mockResolvedValueOnce(mockResponse);
|
||||
|
||||
const result = await validateGitHubToken('test-token');
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain('Invalid credentials');
|
||||
expect(result.retryable).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,398 +0,0 @@
|
||||
/**
|
||||
* Integration tests for PR Review IPC handlers
|
||||
*
|
||||
* Tests the complete flow from IPC trigger to result on disk:
|
||||
* - IPC handler invocation (not manual file writes)
|
||||
* - GitHub API calls (mocked but actually invoked)
|
||||
* - PR review engine execution (mocked but actually invoked)
|
||||
* - File system result storage (via handler, not manual)
|
||||
* - Error scenario handling
|
||||
*
|
||||
* This validates end-to-end integration without making real API calls.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { ipcMain } from 'electron';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { registerPRHandlers } from '../pr-handlers';
|
||||
import * as githubUtils from '../utils';
|
||||
import type { PRReviewResult, PRReviewProgress } from '../pr-handlers';
|
||||
|
||||
// =============================================================================
|
||||
// Mocks - Must be declared before imports
|
||||
// =============================================================================
|
||||
|
||||
const mockStreamText = vi.fn();
|
||||
vi.mock('ai', () => ({
|
||||
streamText: (...args: unknown[]) => mockStreamText(...args),
|
||||
stepCountIs: vi.fn((n: number) => ({ __stepCount: n })),
|
||||
generateObject: vi.fn(),
|
||||
generateText: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockCreateSimpleClient = vi.fn();
|
||||
vi.mock('../../ai/client/factory', () => ({
|
||||
createSimpleClient: (...args: unknown[]) => mockCreateSimpleClient(...args),
|
||||
}));
|
||||
|
||||
// Mock github utils
|
||||
vi.mock('../utils', async () => {
|
||||
const actual = await vi.importActual<typeof import('../utils')>('../utils');
|
||||
return {
|
||||
...actual,
|
||||
githubFetch: vi.fn(),
|
||||
githubFetchWithRetry: vi.fn(),
|
||||
validateGitHubToken: vi.fn(),
|
||||
clearETagCache: vi.fn(),
|
||||
getGitHubConfig: vi.fn(),
|
||||
normalizeRepoReference: vi.fn((repo: string) => repo),
|
||||
githubGraphQL: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock memory service
|
||||
vi.mock('../../context/memory-service-factory', () => ({
|
||||
getMemoryService: vi.fn(() => ({
|
||||
store: vi.fn().mockResolvedValue(undefined),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock Sentry
|
||||
vi.mock('../../sentry', () => ({
|
||||
safeBreadcrumb: vi.fn(),
|
||||
safeCaptureException: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock settings utils
|
||||
vi.mock('../../settings-utils', () => ({
|
||||
readSettingsFile: vi.fn(() => ({
|
||||
featureModels: { githubPrs: 'sonnet' },
|
||||
featureThinking: { githubPrs: 'medium' },
|
||||
})),
|
||||
}));
|
||||
|
||||
// =============================================================================
|
||||
// Test Constants & Helpers
|
||||
// =============================================================================
|
||||
|
||||
const TEST_PR_NUMBER = 42;
|
||||
const TEST_REPO = 'test/repo';
|
||||
const TEST_TOKEN = 'test-token';
|
||||
|
||||
const mockMainWindow = {
|
||||
webContents: {
|
||||
send: vi.fn(),
|
||||
isDestroyed: () => false,
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
once: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
removeAllListeners: vi.fn(),
|
||||
},
|
||||
isDestroyed: () => false,
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
once: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
removeAllListeners: vi.fn(),
|
||||
id: 1,
|
||||
} as any; // eslint-disable-line @typescript-eslint/no-explicit-any -- Mock for testing
|
||||
|
||||
function createMockProject(tempDir: string) {
|
||||
return {
|
||||
id: 'test-project-id',
|
||||
name: 'test-project',
|
||||
path: tempDir,
|
||||
autoBuildPath: '.auto-claude',
|
||||
settings: {
|
||||
model: 'sonnet',
|
||||
memoryBackend: 'memory' as const,
|
||||
linearSync: false,
|
||||
notifications: {
|
||||
onTaskComplete: false,
|
||||
onTaskFailed: false,
|
||||
onReviewNeeded: false,
|
||||
sound: false,
|
||||
},
|
||||
},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
function createMockGitHubPRResponse() {
|
||||
return {
|
||||
number: TEST_PR_NUMBER,
|
||||
title: 'Test PR',
|
||||
body: 'Test description',
|
||||
state: 'open',
|
||||
user: { login: 'testuser' },
|
||||
head: { ref: 'feature', sha: 'abc123' },
|
||||
base: { ref: 'main' },
|
||||
additions: 10,
|
||||
deletions: 5,
|
||||
labels: [],
|
||||
};
|
||||
}
|
||||
|
||||
function createMockFilesResponse() {
|
||||
return [
|
||||
{
|
||||
filename: 'src/test.ts',
|
||||
additions: 10,
|
||||
deletions: 5,
|
||||
status: 'modified',
|
||||
patch: '@@ -1,3 +1,4 @@\n-old line\n+new line',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function createMockCommitsResponse() {
|
||||
return [
|
||||
{
|
||||
sha: 'abc123',
|
||||
commit: {
|
||||
message: 'Test commit\n\nThis is a test',
|
||||
committer: { date: new Date().toISOString() },
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function createMockFinding() {
|
||||
return {
|
||||
id: 'PR-12345678',
|
||||
severity: 'medium' as const,
|
||||
category: 'quality' as const,
|
||||
title: 'Test finding',
|
||||
description: 'This is a test finding',
|
||||
file: 'src/test.ts',
|
||||
line: 10,
|
||||
suggestedFix: 'Fix the issue',
|
||||
fixable: true,
|
||||
sourceAgents: ['quality'],
|
||||
crossValidated: false,
|
||||
};
|
||||
}
|
||||
|
||||
function createMockOrchestratorResult() {
|
||||
return {
|
||||
findings: [createMockFinding()],
|
||||
verdict: 'ready_to_merge' as const,
|
||||
verdictReasoning: 'Code looks good',
|
||||
summary: 'Test summary',
|
||||
blockers: [],
|
||||
agentsInvoked: ['quality', 'security', 'logic', 'codebase-fit'],
|
||||
reviewedCommitSha: 'abc123',
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Test Setup
|
||||
// =============================================================================
|
||||
|
||||
describe('PR Review Integration Tests', () => {
|
||||
let tempDir: string;
|
||||
let project: ReturnType<typeof createMockProject>;
|
||||
let mockGetMainWindow: () => typeof mockMainWindow;
|
||||
let handlersRegistered = false;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create temporary directory for test project
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pr-review-test-'));
|
||||
project = createMockProject(tempDir);
|
||||
|
||||
// Create .auto-claude directory structure
|
||||
const autoClaudeDir = path.join(tempDir, '.auto-claude');
|
||||
fs.mkdirSync(autoClaudeDir, { recursive: true });
|
||||
fs.mkdirSync(path.join(autoClaudeDir, 'github', 'pr'), { recursive: true });
|
||||
|
||||
// Create .env file with GitHub config
|
||||
const envPath = path.join(autoClaudeDir, '.env');
|
||||
fs.writeFileSync(
|
||||
envPath,
|
||||
`GITHUB_TOKEN=${TEST_TOKEN}\nGITHUB_REPO=${TEST_REPO}\n`,
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
// Mock getGitHubConfig to return test config
|
||||
vi.mocked(githubUtils.getGitHubConfig).mockImplementation((proj) => {
|
||||
if (proj.id === project.id) {
|
||||
return {
|
||||
token: TEST_TOKEN,
|
||||
repo: TEST_REPO,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
// Clear all mocks
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Setup main window mock
|
||||
mockGetMainWindow = () => mockMainWindow;
|
||||
|
||||
// Register handlers only once
|
||||
if (!handlersRegistered) {
|
||||
registerPRHandlers(mockGetMainWindow);
|
||||
handlersRegistered = true;
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up temp directory
|
||||
if (fs.existsSync(tempDir)) {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// Remove ipcMain.handle() handlers (must use removeHandler for handle() registrations)
|
||||
const handlerEmitter = ipcMain as { removeHandler?: (channel: string) => void };
|
||||
const channelsToRemove = [
|
||||
'github:pr:review',
|
||||
'github:pr:reviewProgress',
|
||||
'github:pr:reviewError',
|
||||
'github:pr:reviewComplete',
|
||||
'github:pr:logsUpdated',
|
||||
'github:pr:get',
|
||||
'github:authChanged',
|
||||
];
|
||||
channelsToRemove.forEach((channel) => handlerEmitter.removeHandler?.(channel));
|
||||
|
||||
// Remove ipcMain.on() listeners (if any)
|
||||
ipcMain.removeAllListeners('github:pr:reviewProgress');
|
||||
ipcMain.removeAllListeners('github:pr:reviewError');
|
||||
ipcMain.removeAllListeners('github:pr:reviewComplete');
|
||||
ipcMain.removeAllListeners('github:pr:logsUpdated');
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Integration Validation Tests
|
||||
// =============================================================================
|
||||
|
||||
describe('Integration Validation', () => {
|
||||
it('should register IPC handlers correctly', () => {
|
||||
// The handlers are registered in beforeEach, so they should be available
|
||||
const reviewListeners = ipcMain.listenerCount('github:pr:review');
|
||||
expect(reviewListeners).toBeGreaterThan(0);
|
||||
|
||||
const authListeners = ipcMain.listenerCount('github:authChanged');
|
||||
expect(authListeners).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should have main window getter functional', () => {
|
||||
const mainWindow = mockGetMainWindow();
|
||||
expect(mainWindow).toBeDefined();
|
||||
expect(mainWindow.isDestroyed()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return GitHub config for valid project', () => {
|
||||
const config = githubUtils.getGitHubConfig(project);
|
||||
expect(config).not.toBeNull();
|
||||
expect(config!.token).toBe(TEST_TOKEN);
|
||||
expect(config!.repo).toBe(TEST_REPO);
|
||||
});
|
||||
|
||||
it('should return null for invalid project', () => {
|
||||
const invalidProject = {
|
||||
id: 'invalid',
|
||||
name: 'invalid',
|
||||
path: '/invalid',
|
||||
autoBuildPath: '',
|
||||
settings: {
|
||||
model: 'sonnet',
|
||||
memoryBackend: 'memory' as const,
|
||||
linearSync: false,
|
||||
notifications: {
|
||||
onTaskComplete: false,
|
||||
onTaskFailed: false,
|
||||
onReviewNeeded: false,
|
||||
sound: false,
|
||||
},
|
||||
},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
const config = githubUtils.getGitHubConfig(invalidProject);
|
||||
expect(config).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// =============================================================================
|
||||
// File System Integration Tests
|
||||
// =============================================================================
|
||||
|
||||
describe('File System Integration', () => {
|
||||
it('should verify directory structure is created correctly', () => {
|
||||
const autoClaudeDir = path.join(tempDir, '.auto-claude');
|
||||
expect(fs.existsSync(autoClaudeDir)).toBe(true);
|
||||
|
||||
const githubDir = path.join(autoClaudeDir, 'github');
|
||||
expect(fs.existsSync(githubDir)).toBe(true);
|
||||
|
||||
const prDir = path.join(githubDir, 'pr');
|
||||
expect(fs.existsSync(prDir)).toBe(true);
|
||||
});
|
||||
|
||||
it('should verify file system operations work correctly', () => {
|
||||
// Test write operation
|
||||
const testFile = path.join(tempDir, '.auto-claude', 'github', 'pr', 'test.json');
|
||||
const testData = { test: 'data', number: 42 };
|
||||
fs.writeFileSync(testFile, JSON.stringify(testData), 'utf-8');
|
||||
|
||||
// Verify file exists and content is correct
|
||||
expect(fs.existsSync(testFile)).toBe(true);
|
||||
|
||||
const readData = JSON.parse(fs.readFileSync(testFile, 'utf-8'));
|
||||
expect(readData.test).toBe('data');
|
||||
expect(readData.number).toBe(42);
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Mock Configuration Tests
|
||||
// =============================================================================
|
||||
|
||||
describe('Mock Configuration', () => {
|
||||
it('should create valid mock GitHub PR response', () => {
|
||||
const response = createMockGitHubPRResponse();
|
||||
expect(response.number).toBe(TEST_PR_NUMBER);
|
||||
expect(response.title).toBe('Test PR');
|
||||
expect(response.state).toBe('open');
|
||||
expect(response.user.login).toBe('testuser');
|
||||
});
|
||||
|
||||
it('should create valid mock files response', () => {
|
||||
const response = createMockFilesResponse();
|
||||
expect(response).toHaveLength(1);
|
||||
expect(response[0].filename).toBe('src/test.ts');
|
||||
expect(response[0].status).toBe('modified');
|
||||
});
|
||||
|
||||
it('should create valid mock commits response', () => {
|
||||
const response = createMockCommitsResponse();
|
||||
expect(response).toHaveLength(1);
|
||||
expect(response[0].sha).toBe('abc123');
|
||||
});
|
||||
|
||||
it('should create valid mock finding', () => {
|
||||
const finding = createMockFinding();
|
||||
expect(finding.id).toBe('PR-12345678');
|
||||
expect(finding.severity).toBe('medium');
|
||||
expect(finding.category).toBe('quality');
|
||||
expect(finding.file).toBe('src/test.ts');
|
||||
});
|
||||
|
||||
it('should create valid mock orchestrator result', () => {
|
||||
const result = createMockOrchestratorResult();
|
||||
expect(result.findings).toHaveLength(1);
|
||||
expect(result.verdict).toBe('ready_to_merge');
|
||||
expect(result.summary).toBe('Test summary');
|
||||
expect(result.agentsInvoked).toContain('quality');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -146,18 +146,12 @@ vi.mock('../../../ai/runners/github/duplicate-detector', () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
// Hoisted mock functions for circular dependency resolution
|
||||
const mockGithubFetch = vi.fn();
|
||||
const mockGithubFetchWithRetry = vi.fn();
|
||||
|
||||
vi.mock('../utils', () => ({
|
||||
getGitHubConfig: vi.fn(() => ({
|
||||
token: 'mock-github-token',
|
||||
repo: 'owner/repo',
|
||||
})),
|
||||
githubFetch: mockGithubFetch,
|
||||
githubFetchWithRetry: mockGithubFetchWithRetry,
|
||||
validateGitHubToken: vi.fn(() => Promise.resolve({ valid: true })),
|
||||
githubFetch: vi.fn(),
|
||||
normalizeRepoReference: vi.fn((r: string) => r),
|
||||
}));
|
||||
|
||||
@@ -269,8 +263,7 @@ function createProject(): Project {
|
||||
|
||||
describe('GitHub TypeScript runner usage', () => {
|
||||
beforeEach(() => {
|
||||
// Reset all mocks to clean state (resets both call history and implementations)
|
||||
vi.resetAllMocks();
|
||||
vi.clearAllMocks();
|
||||
mockIpcMain.reset();
|
||||
projectRef.current = createProject();
|
||||
});
|
||||
@@ -287,9 +280,12 @@ describe('GitHub TypeScript runner usage', () => {
|
||||
});
|
||||
|
||||
it('calls ParallelOrchestratorReviewer for PR review', async () => {
|
||||
const { githubFetch } = await import('../utils');
|
||||
const githubFetchMock = vi.mocked(githubFetch);
|
||||
|
||||
// Mock GitHub API calls made by the PR review handler
|
||||
// Note: order matters — more specific patterns must come before general ones
|
||||
const mockImplementation = async (_token: string, endpoint: string) => {
|
||||
githubFetchMock.mockImplementation(async (_token: string, endpoint: string) => {
|
||||
if (endpoint === '/user') return { login: 'testuser' };
|
||||
if (endpoint.includes('/assignees')) return {};
|
||||
if (endpoint.includes('/check-runs')) return { check_runs: [], total_count: 0 };
|
||||
@@ -316,15 +312,7 @@ describe('GitHub TypeScript runner usage', () => {
|
||||
labels: [],
|
||||
};
|
||||
return {};
|
||||
};
|
||||
|
||||
// TODO: Currently the code uses githubFetch in some places.
|
||||
// This test stubs both to avoid test failures, but ideally the code
|
||||
// should be refactored to consistently use githubFetchWithRetry.
|
||||
// When that's done, this test should only stub mockGithubFetchWithRetry
|
||||
// and validate that mockGithubFetch is NOT called.
|
||||
mockGithubFetch.mockImplementation(mockImplementation);
|
||||
mockGithubFetchWithRetry.mockImplementation(mockImplementation);
|
||||
});
|
||||
|
||||
// Return the shape that ParallelOrchestratorReviewer.review() produces
|
||||
mockOrchestratorReview.mockResolvedValue({
|
||||
|
||||
@@ -15,9 +15,7 @@
|
||||
*/
|
||||
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import { ipcMain } from 'electron';
|
||||
import { AgentManager } from '../../agent';
|
||||
import { IPC_CHANNELS } from '../../../shared/constants';
|
||||
import { registerRepositoryHandlers } from './repository-handlers';
|
||||
import { registerIssueHandlers } from './issue-handlers';
|
||||
import { registerInvestigationHandlers } from './investigation-handlers';
|
||||
@@ -30,12 +28,11 @@ import { registerTriageHandlers } from './triage-handlers';
|
||||
|
||||
/**
|
||||
* Register all GitHub-related IPC handlers
|
||||
* @returns Cleanup function to remove all listeners
|
||||
*/
|
||||
export function registerGithubHandlers(
|
||||
agentManager: AgentManager,
|
||||
getMainWindow: () => BrowserWindow | null
|
||||
): () => void {
|
||||
): void {
|
||||
registerRepositoryHandlers();
|
||||
registerIssueHandlers();
|
||||
registerInvestigationHandlers(agentManager, getMainWindow);
|
||||
@@ -45,27 +42,6 @@ export function registerGithubHandlers(
|
||||
registerAutoFixHandlers(agentManager, getMainWindow);
|
||||
registerPRHandlers(getMainWindow);
|
||||
registerTriageHandlers(getMainWindow);
|
||||
|
||||
// Return cleanup - removes EventEmitter listeners from sub-handlers
|
||||
// Note: This only cleans up listeners added via ipcMain.on()
|
||||
return (): void => {
|
||||
const emitter = ipcMain as { removeAllListeners?: (event: string) => void };
|
||||
|
||||
// Autofix listeners
|
||||
emitter.removeAllListeners?.(IPC_CHANNELS.GITHUB_AUTOFIX_START);
|
||||
emitter.removeAllListeners?.(IPC_CHANNELS.GITHUB_AUTOFIX_BATCH);
|
||||
emitter.removeAllListeners?.(IPC_CHANNELS.GITHUB_AUTOFIX_ANALYZE_PREVIEW);
|
||||
|
||||
// Auth changed listener
|
||||
emitter.removeAllListeners?.(IPC_CHANNELS.GITHUB_AUTH_CHANGED);
|
||||
|
||||
// PR review listeners
|
||||
emitter.removeAllListeners?.(IPC_CHANNELS.GITHUB_PR_REVIEW);
|
||||
emitter.removeAllListeners?.(IPC_CHANNELS.GITHUB_PR_FOLLOWUP_REVIEW);
|
||||
|
||||
// Triage listeners
|
||||
emitter.removeAllListeners?.(IPC_CHANNELS.GITHUB_TRIAGE_RUN);
|
||||
};
|
||||
}
|
||||
|
||||
// Re-export utilities for potential external use
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
DEFAULT_FEATURE_MODELS,
|
||||
DEFAULT_FEATURE_THINKING,
|
||||
} from "../../../shared/constants";
|
||||
import { getGitHubConfig, githubFetch, githubFetchWithRetry, validateGitHubToken, normalizeRepoReference } from "./utils";
|
||||
import { getGitHubConfig, githubFetch, normalizeRepoReference } from "./utils";
|
||||
import { readSettingsFile } from "../../settings-utils";
|
||||
import { getAugmentedEnv } from "../../env-utils";
|
||||
import { getMemoryService } from "../context/memory-service-factory";
|
||||
@@ -1466,8 +1466,8 @@ async function fetchPRContext(
|
||||
config: { token: string; repo: string },
|
||||
prNumber: number
|
||||
): Promise<PRContext> {
|
||||
// Fetch PR metadata (with retry for 5xx errors)
|
||||
const pr = (await githubFetchWithRetry(
|
||||
// Fetch PR metadata
|
||||
const pr = (await githubFetch(
|
||||
config.token,
|
||||
`/repos/${config.repo}/pulls/${prNumber}`
|
||||
)) as {
|
||||
@@ -1483,8 +1483,8 @@ async function fetchPRContext(
|
||||
labels?: Array<{ name: string }>;
|
||||
};
|
||||
|
||||
// Fetch files with patches (with retry for 5xx errors)
|
||||
const files = (await githubFetchWithRetry(
|
||||
// Fetch files with patches
|
||||
const files = (await githubFetch(
|
||||
config.token,
|
||||
`/repos/${config.repo}/pulls/${prNumber}/files?per_page=100`
|
||||
)) as Array<{
|
||||
@@ -1495,8 +1495,8 @@ async function fetchPRContext(
|
||||
patch?: string;
|
||||
}>;
|
||||
|
||||
// Fetch commits (with retry for 5xx errors)
|
||||
const commits = (await githubFetchWithRetry(
|
||||
// Fetch commits
|
||||
const commits = (await githubFetch(
|
||||
config.token,
|
||||
`/repos/${config.repo}/pulls/${prNumber}/commits?per_page=100`
|
||||
)) as Array<{
|
||||
@@ -2075,7 +2075,7 @@ export function registerPRHandlers(getMainWindow: () => BrowserWindow | null): v
|
||||
|
||||
try {
|
||||
await withProjectOrNull(projectId, async (project) => {
|
||||
const { sendProgress, sendComplete, sendError } = createIPCCommunicators<
|
||||
const { sendProgress, sendComplete } = createIPCCommunicators<
|
||||
PRReviewProgress,
|
||||
PRReviewResult
|
||||
>(
|
||||
@@ -2125,29 +2125,6 @@ export function registerPRHandlers(getMainWindow: () => BrowserWindow | null): v
|
||||
const config = getGitHubConfig(project);
|
||||
if (config) {
|
||||
try {
|
||||
// Validate GitHub token before making API calls
|
||||
const tokenValidation = await validateGitHubToken(config.token);
|
||||
if (!tokenValidation.valid) {
|
||||
debugLog("GitHub token validation failed", {
|
||||
error: tokenValidation.error,
|
||||
retryable: tokenValidation.retryable,
|
||||
});
|
||||
|
||||
// Clean up registry since we registered before CI wait
|
||||
runningReviews.delete(reviewKey);
|
||||
ciWaitAbortControllers.delete(reviewKey);
|
||||
|
||||
// Use retryable field to distinguish transient failures from auth errors
|
||||
const errorMessage = tokenValidation.retryable
|
||||
? `GitHub is temporarily unavailable: ${tokenValidation.error || 'Please try again.'}`
|
||||
: `GitHub authentication failed: ${tokenValidation.error || 'Invalid token'}`;
|
||||
stateManager.handleError(projectId, prNumber, errorMessage);
|
||||
|
||||
sendError({ prNumber, error: errorMessage });
|
||||
return;
|
||||
}
|
||||
debugLog("GitHub token validated successfully");
|
||||
|
||||
// Get current user
|
||||
const user = (await githubFetch(config.token, "/user")) as { login: string };
|
||||
debugLog("Auto-assigning user to PR", { prNumber, username: user.login });
|
||||
|
||||
@@ -11,43 +11,9 @@ import { parseEnvFile } from '../utils';
|
||||
import type { GitHubConfig } from './types';
|
||||
import { getAugmentedEnv } from '../../env-utils';
|
||||
import { getToolPath } from '../../cli-tool-manager';
|
||||
import { debugLog } from './utils/logger';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/**
|
||||
* Retry configuration for githubFetchWithRetry
|
||||
*/
|
||||
const MAX_RETRIES = 3;
|
||||
const RETRY_BASE_DELAY_MS = 1000;
|
||||
const RETRY_EXPONENTIAL_BASE = 2;
|
||||
|
||||
/**
|
||||
* Parse GitHub API error response to extract detailed error message
|
||||
* @param errorBody - Raw error response body
|
||||
* @returns Detailed error message from JSON or original body
|
||||
*/
|
||||
function parseGitHubErrorResponse(errorBody: string): string {
|
||||
try {
|
||||
const errorJson = JSON.parse(errorBody);
|
||||
if (errorJson.message) {
|
||||
return errorJson.message;
|
||||
}
|
||||
} catch {
|
||||
// Not JSON, use original body
|
||||
}
|
||||
return errorBody;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize token to ensure it's a valid non-empty string
|
||||
* @param token - Token to sanitize
|
||||
* @returns Empty string if token is invalid, otherwise the token
|
||||
*/
|
||||
function sanitizeToken(token: string): string {
|
||||
return typeof token === 'string' && token.length > 0 ? token : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* ETag cache entry for conditional requests
|
||||
*/
|
||||
@@ -295,7 +261,7 @@ export async function githubFetch(
|
||||
: `https://api.github.com${endpoint}`;
|
||||
|
||||
// CodeQL: file data in outbound request - validate token is a non-empty string before use
|
||||
const safeToken = sanitizeToken(token);
|
||||
const safeToken = typeof token === 'string' && token.length > 0 ? token : '';
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
@@ -308,8 +274,7 @@ export async function githubFetch(
|
||||
|
||||
if (!response.ok) {
|
||||
const errorBody = await response.text().catch(() => 'Request failed');
|
||||
const detailedError = parseGitHubErrorResponse(errorBody);
|
||||
throw new Error(`GitHub API error (${response.status}): ${detailedError}`);
|
||||
throw new Error(`GitHub API error: ${response.status} - ${errorBody}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
@@ -362,8 +327,7 @@ export async function githubFetchWithETag(
|
||||
|
||||
if (!response.ok) {
|
||||
const errorBody = await response.text().catch(() => 'Request failed');
|
||||
const detailedError = parseGitHubErrorResponse(errorBody);
|
||||
throw new Error(`GitHub API error (${response.status}): ${detailedError}`);
|
||||
throw new Error(`GitHub API error: ${response.status} - ${errorBody}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
@@ -389,132 +353,3 @@ export async function githubFetchWithETag(
|
||||
rateLimitInfo
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a GitHub API request with retry logic for 5xx errors
|
||||
* 500, 502, 503, 504 errors are automatically retried with exponential backoff
|
||||
*/
|
||||
export async function githubFetchWithRetry(
|
||||
token: string,
|
||||
endpoint: string,
|
||||
options: RequestInit = {},
|
||||
maxRetries = MAX_RETRIES
|
||||
): Promise<unknown> {
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
return await githubFetch(token, endpoint, options);
|
||||
} catch (error) {
|
||||
lastError = error as Error;
|
||||
const errorMessage = (error as Error).message;
|
||||
|
||||
// Only retry on 5xx errors (server errors, not client errors)
|
||||
const isServerError = errorMessage.match(/GitHub API error \(5\d\d\):/);
|
||||
|
||||
if (isServerError && attempt < maxRetries) {
|
||||
const delay = RETRY_EXPONENTIAL_BASE ** attempt * RETRY_BASE_DELAY_MS;
|
||||
debugLog('GitHub API', `5xx error detected, retrying in ${delay}ms... (attempt ${attempt + 1}/${maxRetries})`);
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate GitHub token by making a lightweight API call
|
||||
*
|
||||
* Retries transient failures (5xx errors, network issues) to avoid treating
|
||||
* temporary GitHub outages as permanent credential failures.
|
||||
*
|
||||
* @param token - GitHub token to validate
|
||||
* @returns Token validation result with retryable flag for transient errors
|
||||
*/
|
||||
export async function validateGitHubToken(
|
||||
token: string
|
||||
): Promise<{ valid: boolean; error?: string; retryable?: boolean }> {
|
||||
const safeToken = sanitizeToken(token);
|
||||
if (!safeToken) {
|
||||
return { valid: false, error: 'Token is empty', retryable: false };
|
||||
}
|
||||
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
const response = await fetch('https://api.github.com/user', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${safeToken}`,
|
||||
'User-Agent': 'Aperant',
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
// Auth failures (401/403) are permanent, not retryable
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
const errorBody = await response.text().catch(() => 'Unknown error');
|
||||
return {
|
||||
valid: false,
|
||||
error: `Invalid credentials: ${response.status} - ${errorBody.substring(0, 100)}`,
|
||||
retryable: false
|
||||
};
|
||||
}
|
||||
|
||||
// 5xx server errors are retryable
|
||||
if (response.status >= 500 && attempt < MAX_RETRIES) {
|
||||
const delay = RETRY_EXPONENTIAL_BASE ** attempt * RETRY_BASE_DELAY_MS;
|
||||
debugLog('GitHub API', `5xx error during token validation, retrying in ${delay}ms... (attempt ${attempt + 1}/${MAX_RETRIES})`);
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Other client errors (4xx except 401/403) are not retryable
|
||||
const errorBody = await response.text().catch(() => 'Unknown error');
|
||||
return {
|
||||
valid: false,
|
||||
error: `Token validation failed: ${response.status} - ${errorBody.substring(0, 100)}`,
|
||||
retryable: response.status >= 500
|
||||
};
|
||||
} catch (error) {
|
||||
const err = error as Error & { cause?: { code?: string } };
|
||||
lastError = err;
|
||||
const errorMessage = err.message;
|
||||
const causeCode = err.cause?.code;
|
||||
|
||||
// Retry transport failures, but do not retry explicit aborts
|
||||
const isNetworkError =
|
||||
err.name !== 'AbortError' &&
|
||||
(
|
||||
/fetch failed|network\s*error/i.test(errorMessage) ||
|
||||
['ECONNREFUSED', 'ECONNRESET', 'ENOTFOUND', 'ETIMEDOUT', 'EAI_AGAIN'].includes(causeCode ?? '')
|
||||
);
|
||||
|
||||
if (isNetworkError && attempt < MAX_RETRIES) {
|
||||
const delay = RETRY_EXPONENTIAL_BASE ** attempt * RETRY_BASE_DELAY_MS;
|
||||
debugLog('GitHub API', `Network error during token validation, retrying in ${delay}ms... (attempt ${attempt + 1}/${MAX_RETRIES})`);
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
continue;
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
error: errorMessage,
|
||||
retryable: isNetworkError
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Should not reach here, but handle the case
|
||||
return {
|
||||
valid: false,
|
||||
error: lastError?.message || 'Unknown error after retries',
|
||||
retryable: true
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -122,7 +122,6 @@ export function registerIdeationHandlers(
|
||||
agentManager.on("ideation-stopped", handleIdeationStopped);
|
||||
|
||||
return (): void => {
|
||||
// Remove agentManager event listeners
|
||||
agentManager.off("ideation-progress", handleIdeationProgress);
|
||||
agentManager.off("ideation-log", handleIdeationLog);
|
||||
agentManager.off("ideation-type-complete", handleIdeationTypeComplete);
|
||||
@@ -130,10 +129,5 @@ export function registerIdeationHandlers(
|
||||
agentManager.off("ideation-complete", handleIdeationComplete);
|
||||
agentManager.off("ideation-error", handleIdeationError);
|
||||
agentManager.off("ideation-stopped", handleIdeationStopped);
|
||||
|
||||
// Remove ipcMain event listeners
|
||||
const emitter = ipcMain as { removeAllListeners?: (event: string) => void };
|
||||
emitter.removeAllListeners?.(IPC_CHANNELS.IDEATION_GENERATE);
|
||||
emitter.removeAllListeners?.(IPC_CHANNELS.IDEATION_REFRESH);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// ============================================
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* - Switching to OAuth (null profileId)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { APIProfile, ProfilesFile } from '@shared/types/profile';
|
||||
|
||||
// Hoist mocked functions to avoid circular dependency in atomicModifyProfiles
|
||||
@@ -72,15 +72,9 @@ function getTestConnectionHandler() {
|
||||
}
|
||||
|
||||
describe('profile-handlers - setActiveProfile', () => {
|
||||
let cleanup: () => void;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
cleanup = registerProfileHandlers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup?.();
|
||||
registerProfileHandlers();
|
||||
});
|
||||
const mockProfiles: APIProfile[] = [
|
||||
{
|
||||
@@ -226,15 +220,9 @@ describe('profile-handlers - setActiveProfile', () => {
|
||||
});
|
||||
|
||||
describe('profile-handlers - testConnection', () => {
|
||||
let cleanup: () => void;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
cleanup = registerProfileHandlers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup?.();
|
||||
registerProfileHandlers();
|
||||
});
|
||||
|
||||
describe('successful connection tests', () => {
|
||||
|
||||
@@ -34,9 +34,8 @@ const activeDiscoverModelsRequests = new Map<number, AbortController>();
|
||||
|
||||
/**
|
||||
* Register all profile-related IPC handlers
|
||||
* @returns Cleanup function to remove all handlers
|
||||
*/
|
||||
export function registerProfileHandlers(): () => void {
|
||||
export function registerProfileHandlers(): void {
|
||||
/**
|
||||
* Get all profiles
|
||||
*/
|
||||
@@ -355,35 +354,4 @@ export function registerProfileHandlers(): () => void {
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Return cleanup function to remove IPC handlers and EventEmitter listeners
|
||||
return (): void => {
|
||||
// Abort and clear in-flight requests to avoid dangling work during teardown
|
||||
for (const controller of activeTestConnections.values()) {
|
||||
controller.abort();
|
||||
}
|
||||
activeTestConnections.clear();
|
||||
|
||||
for (const controller of activeDiscoverModelsRequests.values()) {
|
||||
controller.abort();
|
||||
}
|
||||
activeDiscoverModelsRequests.clear();
|
||||
|
||||
// Remove ipcMain.handle() registrations
|
||||
const handlerEmitter = ipcMain as { removeHandler?: (channel: string) => void };
|
||||
[
|
||||
IPC_CHANNELS.PROFILES_GET,
|
||||
IPC_CHANNELS.PROFILES_SAVE,
|
||||
IPC_CHANNELS.PROFILES_UPDATE,
|
||||
IPC_CHANNELS.PROFILES_DELETE,
|
||||
IPC_CHANNELS.PROFILES_SET_ACTIVE,
|
||||
IPC_CHANNELS.PROFILES_TEST_CONNECTION,
|
||||
IPC_CHANNELS.PROFILES_DISCOVER_MODELS,
|
||||
].forEach((channel) => handlerEmitter.removeHandler?.(channel));
|
||||
|
||||
// Remove on() registrations (these are EventEmitter listeners)
|
||||
const emitter = ipcMain as { removeAllListeners?: (event: string) => void };
|
||||
emitter.removeAllListeners?.(IPC_CHANNELS.PROFILES_TEST_CONNECTION_CANCEL);
|
||||
emitter.removeAllListeners?.(IPC_CHANNELS.PROFILES_DISCOVER_MODELS_CANCEL);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -1,326 +0,0 @@
|
||||
/**
|
||||
* Vercel AI SDK Mock Utilities
|
||||
* ============================
|
||||
*
|
||||
* Mock helpers for Vercel AI SDK v6 functions (generateText, streamText).
|
||||
* Supports single-pass review and multi-turn agent session mocking.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Mock generateText response for single-pass review
|
||||
*
|
||||
* @param result - Mock result object
|
||||
* @returns Mocked generateText return value
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const mockResult = mockGenerateText({
|
||||
* text: '{"findings": [{"id": "SEC-1", "severity": "high"}]}',
|
||||
* usage: { promptTokens: 1000, completionTokens: 500 }
|
||||
* });
|
||||
*
|
||||
* vi.mocked(generateText).mockResolvedValue(mockResult);
|
||||
* ```
|
||||
*/
|
||||
export interface MockGenerateTextResult {
|
||||
text?: string;
|
||||
object?: unknown;
|
||||
usage?: {
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
};
|
||||
finishReason?: 'stop' | 'length' | 'error';
|
||||
toolCalls?: Array<{
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
args: Record<string, unknown>;
|
||||
}>;
|
||||
}
|
||||
|
||||
export function mockGenerateText(result: MockGenerateTextResult = {}): {
|
||||
text: string;
|
||||
object?: unknown;
|
||||
usage: {
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
};
|
||||
finishReason: 'stop' | 'length' | 'error';
|
||||
warnings?: unknown[];
|
||||
response?: unknown;
|
||||
} {
|
||||
return {
|
||||
text: result.text || '',
|
||||
object: result.object,
|
||||
usage: result.usage || {
|
||||
promptTokens: 1000,
|
||||
completionTokens: 500,
|
||||
totalTokens: 1500
|
||||
},
|
||||
finishReason: result.finishReason || 'stop',
|
||||
toolCalls: result.toolCalls
|
||||
} as unknown as {
|
||||
text: string;
|
||||
object?: unknown;
|
||||
usage: { promptTokens: number; completionTokens: number; totalTokens: number };
|
||||
finishReason: 'stop' | 'length' | 'error';
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock streamText steps for multi-turn agent sessions
|
||||
*
|
||||
* Simulates a multi-step agent session with tool calls.
|
||||
*
|
||||
* @param steps - Array of mock steps
|
||||
* @returns Mock streamText result with async iterator
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const mockStream = mockStreamText([
|
||||
* {
|
||||
* text: 'Let me check the code...',
|
||||
* toolCalls: [{ toolName: 'read_file', args: { path: 'src/test.ts' } }]
|
||||
* },
|
||||
* {
|
||||
* text: 'Found the issue!',
|
||||
* finishReason: 'stop'
|
||||
* }
|
||||
* ]);
|
||||
*
|
||||
* vi.mocked(streamText).mockReturnValue(mockStream);
|
||||
* ```
|
||||
*/
|
||||
export interface MockStreamStep {
|
||||
text?: string;
|
||||
toolCalls?: Array<{
|
||||
toolName: string;
|
||||
args: Record<string, unknown>;
|
||||
}>;
|
||||
finishReason?: 'stop' | 'length' | 'error';
|
||||
toolResults?: Array<{
|
||||
toolCallId: string;
|
||||
result: unknown;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Counter for generating deterministic tool call IDs
|
||||
*/
|
||||
let toolCallIdCounter = 0;
|
||||
|
||||
/**
|
||||
* Generate a deterministic tool call ID
|
||||
*
|
||||
* @param toolName - Name of the tool
|
||||
* @returns Deterministic tool call ID
|
||||
*/
|
||||
function generateToolCallId(toolName: string): string {
|
||||
return `mock-${toolName}-${toolCallIdCounter++}`;
|
||||
}
|
||||
|
||||
export function mockStreamText(steps: MockStreamStep[] = []): {
|
||||
toDataStreamStream: () => ReadableStream;
|
||||
text: string;
|
||||
usage: { promptTokens: number; completionTokens: number; totalTokens: number };
|
||||
finishReason: 'stop' | 'length' | 'error';
|
||||
toolCalls: Array<{
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
args: Record<string, unknown>;
|
||||
}>;
|
||||
fullStream: AsyncIterable<unknown>;
|
||||
} {
|
||||
const allText = steps.map(s => s.text || '').join('');
|
||||
|
||||
// Precompute deterministic toolCallIds for each tool call in each step
|
||||
const stepToolCallIds: Array<Array<string>> = steps.map(step =>
|
||||
(step.toolCalls || []).map(tc => generateToolCallId(tc.toolName))
|
||||
);
|
||||
|
||||
// Build allToolCalls using precomputed IDs
|
||||
const allToolCalls = steps.flatMap((step, stepIndex) =>
|
||||
(step.toolCalls || []).map((tc, toolIndex) => ({
|
||||
toolCallId: stepToolCallIds[stepIndex][toolIndex],
|
||||
toolName: tc.toolName,
|
||||
args: tc.args
|
||||
}))
|
||||
);
|
||||
|
||||
// Create async generator for fullStream
|
||||
async function* generateSteps() {
|
||||
for (const step of steps) {
|
||||
yield {
|
||||
type: 'text-delta',
|
||||
textDelta: step.text || ''
|
||||
};
|
||||
|
||||
if (step.toolCalls && step.toolCalls.length > 0) {
|
||||
const stepIndex = steps.indexOf(step);
|
||||
for (const tc of step.toolCalls) {
|
||||
const toolIndex = step.toolCalls!.indexOf(tc);
|
||||
yield {
|
||||
type: 'tool-call',
|
||||
toolName: tc.toolName,
|
||||
toolCallId: stepToolCallIds[stepIndex][toolIndex],
|
||||
args: tc.args
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Emit toolResults if present
|
||||
if (step.toolResults && step.toolResults.length > 0) {
|
||||
for (const tr of step.toolResults) {
|
||||
yield {
|
||||
type: 'tool-result',
|
||||
toolCallId: tr.toolCallId,
|
||||
result: tr.result
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (step.finishReason) {
|
||||
yield {
|
||||
type: 'finish',
|
||||
finishReason: step.finishReason,
|
||||
usage: {
|
||||
promptTokens: 1000,
|
||||
completionTokens: 500
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
toDataStreamStream: () =>
|
||||
new ReadableStream({
|
||||
async start(controller) {
|
||||
for (const step of steps) {
|
||||
if (step.text) {
|
||||
controller.enqueue(
|
||||
new TextEncoder().encode(`data: ${JSON.stringify({ type: 'text-delta', textDelta: step.text })}\n\n`)
|
||||
);
|
||||
}
|
||||
}
|
||||
controller.close();
|
||||
}
|
||||
}),
|
||||
text: allText,
|
||||
usage: {
|
||||
promptTokens: 1000 * steps.length,
|
||||
completionTokens: 500 * steps.length,
|
||||
totalTokens: 1500 * steps.length
|
||||
},
|
||||
finishReason: steps[steps.length - 1]?.finishReason || 'stop',
|
||||
toolCalls: allToolCalls,
|
||||
fullStream: generateSteps()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create mock AI client
|
||||
*
|
||||
* Returns a minimal mock client with model and systemPrompt.
|
||||
*
|
||||
* @param modelOverride - Optional model name override
|
||||
* @param systemPromptOverride - Optional system prompt override
|
||||
* @returns Mock client object
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const client = createMockAIClient('claude-3-5-sonnet-20241022');
|
||||
* expect(client.model).toBe('claude-3-5-sonnet-20241022');
|
||||
* ```
|
||||
*/
|
||||
export function createMockAIClient(
|
||||
modelOverride: string = 'claude-3-5-sonnet-20241022',
|
||||
systemPromptOverride: string = 'You are a helpful assistant.'
|
||||
): {
|
||||
model: string;
|
||||
systemPrompt: string;
|
||||
} {
|
||||
return {
|
||||
model: modelOverride,
|
||||
systemPrompt: systemPromptOverride
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock AI SDK tool execution
|
||||
*
|
||||
* Creates a mock tool result for testing tool call handling.
|
||||
*
|
||||
* @param toolName - Name of the tool
|
||||
* @param result - Tool execution result
|
||||
* @param toolCallId - Optional explicit tool call ID (for deterministic testing)
|
||||
* @returns Mock tool result
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Auto-generated ID (non-deterministic)
|
||||
* const mockResult = mockToolResult('read_file', {
|
||||
* file_path: 'src/test.ts',
|
||||
* content: 'export const test = true;'
|
||||
* });
|
||||
*
|
||||
* // Explicit ID (deterministic, correlates with mockStreamText)
|
||||
* const mockResult = mockToolResult('read_file', {
|
||||
* file_path: 'src/test.ts',
|
||||
* content: 'export const test = true;'
|
||||
* }, 'mock-read_file-0');
|
||||
* ```
|
||||
*/
|
||||
export function mockToolResult<T = unknown>(
|
||||
toolName: string,
|
||||
result: T,
|
||||
toolCallId?: string
|
||||
): {
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
args: Record<string, unknown>;
|
||||
result: T;
|
||||
} {
|
||||
return {
|
||||
toolCallId: toolCallId || generateToolCallId(toolName),
|
||||
toolName,
|
||||
args: {},
|
||||
result
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock conversation history
|
||||
*
|
||||
* Creates a mock array of message objects for testing.
|
||||
*
|
||||
* @param userMessages - Array of user message strings
|
||||
* @param assistantMessages - Array of assistant message strings
|
||||
* @returns Mock conversation history
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const history = mockConversationHistory(
|
||||
* ['Review this PR', 'Check for security issues'],
|
||||
* ['I will review it', 'Found 2 security issues']
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
export function mockConversationHistory(
|
||||
userMessages: string[] = [],
|
||||
assistantMessages: string[] = []
|
||||
): Array<{ role: 'user' | 'assistant'; content: string }> {
|
||||
const history: Array<{ role: 'user' | 'assistant'; content: string }> = [];
|
||||
|
||||
for (let i = 0; i < Math.max(userMessages.length, assistantMessages.length); i++) {
|
||||
if (i < userMessages.length) {
|
||||
history.push({ role: 'user', content: userMessages[i] });
|
||||
}
|
||||
if (i < assistantMessages.length) {
|
||||
history.push({ role: 'assistant', content: assistantMessages[i] });
|
||||
}
|
||||
}
|
||||
|
||||
return history;
|
||||
}
|
||||
@@ -1,218 +0,0 @@
|
||||
/**
|
||||
* GitHub API Mock Utilities
|
||||
* =========================
|
||||
*
|
||||
* Mock helpers for GitHub REST and GraphQL API responses in PR review tests.
|
||||
* Supports Vitest vi.fn() mocking patterns.
|
||||
*/
|
||||
|
||||
import { vi } from 'vitest';
|
||||
import type { PRData } from '@preload/api/modules/github-api';
|
||||
|
||||
/**
|
||||
* Mock successful GitHub PR fetch response
|
||||
*
|
||||
* @param prNumber - PR number
|
||||
* @param overrides - Optional overrides for default PR data
|
||||
* @returns Mock PR data object
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const mockPR = mockSuccessfulPR(42, {
|
||||
* title: 'Fix authentication bug',
|
||||
* additions: 100
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function mockSuccessfulPR(prNumber: number, overrides: Partial<PRData> = {}): PRData {
|
||||
return {
|
||||
number: prNumber,
|
||||
title: `Test PR #${prNumber}`,
|
||||
body: 'Test PR description',
|
||||
state: 'open',
|
||||
author: { login: 'testuser' },
|
||||
headRefName: 'feature/test-branch',
|
||||
baseRefName: 'develop',
|
||||
additions: 50,
|
||||
deletions: 20,
|
||||
changedFiles: 5,
|
||||
assignees: [],
|
||||
files: [
|
||||
{
|
||||
path: 'src/test.ts',
|
||||
additions: 30,
|
||||
deletions: 10,
|
||||
status: 'modified'
|
||||
},
|
||||
{
|
||||
path: 'src/utils/helpers.ts',
|
||||
additions: 20,
|
||||
deletions: 10,
|
||||
status: 'modified'
|
||||
}
|
||||
],
|
||||
createdAt: '2025-01-15T10:00:00Z',
|
||||
updatedAt: '2025-01-15T12:00:00Z',
|
||||
htmlUrl: `https://github.com/test/repo/pull/${prNumber}`,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock GitHub API error response
|
||||
*
|
||||
* @param status - HTTP status code
|
||||
* @param errorBody - Error response body
|
||||
* @returns Mock error response
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const error = mockGitHubError(401, {
|
||||
* message: 'Bad credentials',
|
||||
* documentation_url: 'https://docs.github.com/rest'
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export interface GitHubErrorBody {
|
||||
message: string;
|
||||
documentation_url?: string;
|
||||
errors?: Array<{ resource: string; field: string; code: string }>;
|
||||
}
|
||||
|
||||
export function mockGitHubError(status: number, errorBody: Partial<GitHubErrorBody> = {}): {
|
||||
status: number;
|
||||
data: GitHubErrorBody;
|
||||
} {
|
||||
return {
|
||||
status,
|
||||
data: {
|
||||
message: 'An error occurred',
|
||||
documentation_url: 'https://docs.github.com/rest',
|
||||
...errorBody
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup mock fetch for GitHub API
|
||||
*
|
||||
* Configures global.fetch to return mocked GitHub responses.
|
||||
* Use with vi.restoreAllMocks() in afterEach cleanup.
|
||||
*
|
||||
* @param mock - Mock response data or error
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* beforeEach(() => {
|
||||
* setupMockGitHubFetch({
|
||||
* ok: true,
|
||||
* status: 200,
|
||||
* json: async () => mockSuccessfulPR(42)
|
||||
* });
|
||||
* });
|
||||
*
|
||||
* afterEach(() => {
|
||||
* vi.restoreAllMocks();
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export interface MockFetchResponse {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
statusText?: string;
|
||||
headers?: Headers;
|
||||
json: () => Promise<unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup mock fetch for GitHub API
|
||||
*
|
||||
* Configures global.fetch to return mocked GitHub responses.
|
||||
* Returns the mock function for test assertions.
|
||||
* Use with vi.restoreAllMocks() in afterEach cleanup.
|
||||
*
|
||||
* @param mock - Mock response data or error
|
||||
* @returns The mock fetch function for assertions
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* beforeEach(() => {
|
||||
* mockFetch = setupMockGitHubFetch({
|
||||
* ok: true,
|
||||
* status: 200,
|
||||
* json: async () => mockSuccessfulPR(42)
|
||||
* });
|
||||
* });
|
||||
*
|
||||
* afterEach(() => {
|
||||
* vi.restoreAllMocks();
|
||||
* });
|
||||
*
|
||||
* test('fetches PR data', async () => {
|
||||
* expect(mockFetch).toHaveBeenCalled();
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function setupMockGitHubFetch(mock: MockFetchResponse): ReturnType<typeof vi.fn> {
|
||||
const mockFetch = vi.fn(() => Promise.resolve(mock as Response)) as unknown as typeof fetch;
|
||||
global.fetch = mockFetch;
|
||||
return mockFetch as unknown as ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock GitHub GraphQL response
|
||||
*
|
||||
* @param data - GraphQL response data
|
||||
* @param errors - Optional GraphQL errors
|
||||
* @returns Mock GraphQL response
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const mockResponse = mockGraphQLResponse({
|
||||
* repository: {
|
||||
* pullRequest: mockSuccessfulPR(42)
|
||||
* }
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export interface GraphQLError {
|
||||
message: string;
|
||||
path?: (string | number)[];
|
||||
locations?: Array<{ line: number; column: number }>;
|
||||
extensions?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function mockGraphQLResponse(data: Record<string, unknown>, errors: GraphQLError[] = []): {
|
||||
data: Record<string, unknown>;
|
||||
errors?: GraphQLError[];
|
||||
} {
|
||||
const response: { data: Record<string, unknown>; errors?: GraphQLError[] } = { data };
|
||||
if (errors.length > 0) {
|
||||
response.errors = errors;
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock GitHub rate limit error response
|
||||
*
|
||||
* @param retryAfter - Seconds until retry (default 60)
|
||||
* @returns Mock rate limit error
|
||||
*/
|
||||
export function mockRateLimitError(retryAfter: number = 60): MockFetchResponse {
|
||||
const headers = new Headers({
|
||||
'x-ratelimit-remaining': '0',
|
||||
'x-ratelimit-reset': String(Math.floor(Date.now() / 1000) + retryAfter),
|
||||
'retry-after': String(retryAfter),
|
||||
});
|
||||
return {
|
||||
ok: false,
|
||||
status: 403,
|
||||
statusText: 'Forbidden',
|
||||
headers,
|
||||
json: async () => ({
|
||||
message: 'API rate limit exceeded',
|
||||
documentation_url: 'https://docs.github.com/rest/rate-limit'
|
||||
})
|
||||
};
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
/**
|
||||
* Shared Test Utilities for PR Review Suite
|
||||
* ==========================================
|
||||
*
|
||||
* Barrel exports for all test utility modules.
|
||||
* Import from this file for cleaner imports:
|
||||
*
|
||||
* ```ts
|
||||
* import { createMockProject, createMockFinding } from '@shared/test-utils';
|
||||
* ```
|
||||
*/
|
||||
|
||||
// GitHub API mocks
|
||||
export {
|
||||
mockSuccessfulPR,
|
||||
mockGitHubError,
|
||||
setupMockGitHubFetch,
|
||||
mockGraphQLResponse,
|
||||
mockRateLimitError
|
||||
} from './github-mocks';
|
||||
|
||||
// GitHub API types
|
||||
export type { MockFetchResponse, GitHubErrorBody, GraphQLError } from './github-mocks';
|
||||
|
||||
// Vercel AI SDK mocks
|
||||
export {
|
||||
mockGenerateText,
|
||||
mockStreamText,
|
||||
createMockAIClient,
|
||||
mockToolResult,
|
||||
mockConversationHistory
|
||||
} from './ai-sdk-mocks';
|
||||
|
||||
// Vercel AI SDK types
|
||||
export type { MockGenerateTextResult, MockStreamStep } from './ai-sdk-mocks';
|
||||
|
||||
// PR fixtures
|
||||
export {
|
||||
SIMPLE_PR_CONTEXT,
|
||||
COMPLEX_PR_CONTEXT,
|
||||
PR_WITH_SECURITY_ISSUE,
|
||||
PR_WITH_AI_COMMENTS,
|
||||
EMPTY_PR_CONTEXT
|
||||
} from './pr-fixtures';
|
||||
|
||||
// Mock factories
|
||||
export {
|
||||
createMockProject,
|
||||
createMockGitHubConfig,
|
||||
createMockPRContext,
|
||||
createMockFinding,
|
||||
createMockReviewResult,
|
||||
createMockProgress,
|
||||
createMockChangedFile,
|
||||
createMockAIComment,
|
||||
createMockFindings,
|
||||
createMockScanResult,
|
||||
PRContextBuilder
|
||||
} from './mock-factories';
|
||||
|
||||
// Mock factory types
|
||||
export type { MockScanResult } from './mock-factories';
|
||||
@@ -1,426 +0,0 @@
|
||||
/**
|
||||
* Mock Factories for PR Review Tests
|
||||
* ==================================
|
||||
*
|
||||
* Factory functions to create test data objects with sensible defaults.
|
||||
* Follows the builder pattern for flexible test setup.
|
||||
*/
|
||||
|
||||
import type { Project } from '../../shared/types/project';
|
||||
import type { GitHubConfig } from '../../main/ipc-handlers/github/types';
|
||||
import type { PRContext, PRReviewFinding, ChangedFile, AIBotComment } from '../../main/ai/runners/github/pr-review-engine';
|
||||
import type { PRReviewResult, PRReviewProgress } from '../../preload/api/modules/github-api';
|
||||
|
||||
/**
|
||||
* Create mock Project with GitHub configuration
|
||||
*
|
||||
* @param overrides - Optional property overrides
|
||||
* @returns Mock Project object
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const project = createMockProject({
|
||||
* name: 'test-project',
|
||||
* path: '/tmp/test'
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function createMockProject(overrides: Partial<Project> = {}): Project {
|
||||
const now = new Date();
|
||||
return {
|
||||
id: 'project-test-id',
|
||||
name: 'Test Project',
|
||||
path: '/tmp/test-project',
|
||||
autoBuildPath: '/tmp/test-project/.auto-claude',
|
||||
settings: {
|
||||
model: 'claude-3-5-sonnet-20241022',
|
||||
memoryBackend: 'file',
|
||||
linearSync: false,
|
||||
notifications: {
|
||||
onTaskComplete: true,
|
||||
onTaskFailed: true,
|
||||
onReviewNeeded: true,
|
||||
sound: false
|
||||
}
|
||||
},
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create mock GitHub configuration
|
||||
*
|
||||
* @param token - GitHub personal access token
|
||||
* @param repo - Repository in 'owner/repo' format
|
||||
* @param overrides - Optional property overrides
|
||||
* @returns Mock GitHubConfig object
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const config = createMockGitHubConfig('ghp_123', 'owner/repo');
|
||||
* ```
|
||||
*/
|
||||
export function createMockGitHubConfig(
|
||||
token: string = 'ghp_test_token',
|
||||
repo: string = 'owner/repo',
|
||||
overrides: Partial<GitHubConfig> = {}
|
||||
): GitHubConfig {
|
||||
return {
|
||||
token,
|
||||
repo,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create mock PR context with defaults
|
||||
*
|
||||
* @param overrides - Optional property overrides
|
||||
* @returns Mock PRContext object
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const context = createMockPRContext({
|
||||
* prNumber: 42,
|
||||
* title: 'Fix bug'
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function createMockPRContext(overrides: Partial<PRContext> = {}): PRContext {
|
||||
return {
|
||||
prNumber: 42,
|
||||
title: 'Test PR',
|
||||
description: 'Test PR description',
|
||||
author: 'testuser',
|
||||
baseBranch: 'develop',
|
||||
headBranch: 'feature/test',
|
||||
state: 'open',
|
||||
changedFiles: [
|
||||
{
|
||||
path: 'src/test.ts',
|
||||
additions: 10,
|
||||
deletions: 5,
|
||||
status: 'modified'
|
||||
}
|
||||
] as ChangedFile[],
|
||||
diff: 'diff --git a/src/test.ts b/src/test.ts\n+new line',
|
||||
diffTruncated: false,
|
||||
repoStructure: 'src/\n└── test.ts',
|
||||
relatedFiles: [],
|
||||
commits: [
|
||||
{
|
||||
oid: 'abc123',
|
||||
messageHeadline: 'Test commit',
|
||||
committedDate: new Date().toISOString()
|
||||
}
|
||||
],
|
||||
labels: [],
|
||||
totalAdditions: 10,
|
||||
totalDeletions: 5,
|
||||
aiBotComments: [],
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create mock review finding
|
||||
*
|
||||
* @param overrides - Optional property overrides
|
||||
* @returns Mock PRReviewFinding object
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const finding = createMockFinding({
|
||||
* severity: 'critical',
|
||||
* category: 'security'
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function createMockFinding(overrides: Partial<PRReviewFinding> = {}): PRReviewFinding {
|
||||
return {
|
||||
id: 'TEST-001',
|
||||
severity: 'medium',
|
||||
category: 'quality' as const,
|
||||
title: 'Test Finding',
|
||||
description: 'This is a test finding',
|
||||
file: 'src/test.ts',
|
||||
line: 10,
|
||||
endLine: 15,
|
||||
suggestedFix: 'Fix the issue',
|
||||
fixable: true,
|
||||
evidence: 'const x = 1;',
|
||||
verificationNote: 'Verified manually',
|
||||
validationStatus: 'needs_human_review',
|
||||
validationExplanation: 'Needs review',
|
||||
sourceAgents: ['security-specialist'],
|
||||
crossValidated: false,
|
||||
...overrides
|
||||
} as PRReviewFinding;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create mock review result
|
||||
*
|
||||
* @param overrides - Optional property overrides
|
||||
* @returns Mock PRReviewResult object
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const result = createMockReviewResult({
|
||||
* prNumber: 42,
|
||||
* overallStatus: 'approve'
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function createMockReviewResult(overrides: Partial<PRReviewResult> = {}): PRReviewResult {
|
||||
// createMockFinding returns a broader ReviewCategory type that includes 'verification_failed'
|
||||
// but the API type only accepts the narrower set. Since createMockFinding always returns
|
||||
// 'quality', we cast directly to the narrower type.
|
||||
const mockFinding = createMockFinding() as PRReviewResult['findings'][number];
|
||||
|
||||
return {
|
||||
prNumber: 42,
|
||||
repo: 'test/repo',
|
||||
success: true,
|
||||
findings: [mockFinding],
|
||||
summary: 'LGTM, looks good to merge',
|
||||
overallStatus: 'approve',
|
||||
reviewedAt: new Date().toISOString(),
|
||||
error: undefined,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create mock review progress
|
||||
*
|
||||
* @param overrides - Optional property overrides
|
||||
* @returns Mock PRReviewProgress object
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const progress = createMockProgress({
|
||||
* phase: 'analyzing',
|
||||
* progress: 50
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function createMockProgress(overrides: Partial<PRReviewProgress> = {}): PRReviewProgress {
|
||||
return {
|
||||
phase: 'fetching',
|
||||
prNumber: 42,
|
||||
progress: 0,
|
||||
message: 'Starting review...',
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create mock changed file
|
||||
*
|
||||
* @param path - File path
|
||||
* @param additions - Number of additions
|
||||
* @param deletions - Number of deletions
|
||||
* @param overrides - Optional property overrides
|
||||
* @returns Mock ChangedFile object
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const file = createMockChangedFile('src/test.ts', 10, 5);
|
||||
* ```
|
||||
*/
|
||||
export function createMockChangedFile(
|
||||
path: string = 'src/test.ts',
|
||||
additions: number = 10,
|
||||
deletions: number = 5,
|
||||
overrides: Partial<ChangedFile> = {}
|
||||
): ChangedFile {
|
||||
return {
|
||||
path,
|
||||
additions,
|
||||
deletions,
|
||||
status: 'modified',
|
||||
patch: `@@ -1,1 +1,2 @@
|
||||
-line 1
|
||||
+line 1 modified`,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create mock AI bot comment
|
||||
*
|
||||
* @param toolName - Name of the AI tool
|
||||
* @param body - Comment body text
|
||||
* @param overrides - Optional property overrides
|
||||
* @returns Mock AIBotComment object
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const comment = createMockAIComment('CodeRabbit', 'Add error handling');
|
||||
* ```
|
||||
*/
|
||||
export function createMockAIComment(
|
||||
toolName: string = 'CodeRabbit',
|
||||
body: string = 'This looks good but could be improved',
|
||||
overrides: Partial<AIBotComment> = {}
|
||||
): AIBotComment {
|
||||
return {
|
||||
commentId: 1,
|
||||
author: 'ai-bot',
|
||||
toolName,
|
||||
body,
|
||||
file: 'src/test.ts',
|
||||
line: 10,
|
||||
createdAt: new Date().toISOString(),
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create multiple mock findings with auto-incrementing IDs
|
||||
*
|
||||
* @param count - Number of findings to create
|
||||
* @param baseOverrides - Overrides to apply to all findings
|
||||
* @returns Array of mock PRReviewFinding objects
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const findings = createMockFindings(3, {
|
||||
* severity: 'high',
|
||||
* category: 'security'
|
||||
* });
|
||||
* // Creates TEST-001, TEST-002, TEST-003 all with high severity
|
||||
* ```
|
||||
*/
|
||||
export function createMockFindings(
|
||||
count: number,
|
||||
baseOverrides: Partial<PRReviewFinding> = {}
|
||||
): PRReviewFinding[] {
|
||||
return Array.from({ length: count }, (_, i) =>
|
||||
createMockFinding({
|
||||
...baseOverrides,
|
||||
id: `TEST-${String(i + 1).padStart(3, '0')}`,
|
||||
title: `${baseOverrides.title || 'Test Finding'} ${i + 1}`,
|
||||
line: 10 + i * 5
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create mock scan result
|
||||
*
|
||||
* @param complexity - Complexity level
|
||||
* @param riskAreas - Array of risk area strings
|
||||
* @returns Mock scan result
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const scanResult = createMockScanResult('high', ['authentication', 'database']);
|
||||
* ```
|
||||
*/
|
||||
export interface MockScanResult {
|
||||
complexity: 'low' | 'medium' | 'high';
|
||||
riskAreas: string[];
|
||||
verdict?: string;
|
||||
summary?: string;
|
||||
}
|
||||
|
||||
export function createMockScanResult(
|
||||
complexity: 'low' | 'medium' | 'high' = 'medium',
|
||||
riskAreas: string[] = [],
|
||||
overrides: Partial<MockScanResult> = {}
|
||||
): MockScanResult {
|
||||
return {
|
||||
complexity,
|
||||
riskAreas,
|
||||
verdict: 'needs_review',
|
||||
summary: 'PR requires review',
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builder for creating complex PR contexts
|
||||
*
|
||||
* Provides a fluent interface for building PR contexts.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const context = new PRContextBuilder()
|
||||
* .withPrNumber(42)
|
||||
* .withFiles([
|
||||
* createMockChangedFile('src/a.ts', 10, 0),
|
||||
* createMockChangedFile('src/b.ts', 20, 5)
|
||||
* ])
|
||||
* .withLabels('bug', 'high-priority')
|
||||
* .withAIComment(createMockAIComment('CodeRabbit', 'Fix this'))
|
||||
* .build();
|
||||
* ```
|
||||
*/
|
||||
export class PRContextBuilder {
|
||||
private context: Partial<PRContext> = {};
|
||||
|
||||
withPrNumber(prNumber: number): this {
|
||||
this.context.prNumber = prNumber;
|
||||
return this;
|
||||
}
|
||||
|
||||
withTitle(title: string): this {
|
||||
this.context.title = title;
|
||||
return this;
|
||||
}
|
||||
|
||||
withDescription(description: string): this {
|
||||
this.context.description = description;
|
||||
return this;
|
||||
}
|
||||
|
||||
withAuthor(author: string): this {
|
||||
this.context.author = author;
|
||||
return this;
|
||||
}
|
||||
|
||||
withBranches(base: string, head: string): this {
|
||||
this.context.baseBranch = base;
|
||||
this.context.headBranch = head;
|
||||
return this;
|
||||
}
|
||||
|
||||
withFiles(files: ChangedFile[]): this {
|
||||
this.context.changedFiles = files;
|
||||
this.context.totalAdditions = files.reduce((sum, f) => sum + f.additions, 0);
|
||||
this.context.totalDeletions = files.reduce((sum, f) => sum + f.deletions, 0);
|
||||
return this;
|
||||
}
|
||||
|
||||
withLabels(...labels: string[]): this {
|
||||
this.context.labels = labels;
|
||||
return this;
|
||||
}
|
||||
|
||||
withAIComment(comment: AIBotComment): this {
|
||||
if (!this.context.aiBotComments) {
|
||||
this.context.aiBotComments = [];
|
||||
}
|
||||
this.context.aiBotComments.push(comment);
|
||||
return this;
|
||||
}
|
||||
|
||||
withDiff(diff: string, truncated: boolean = false): this {
|
||||
this.context.diff = diff;
|
||||
this.context.diffTruncated = truncated;
|
||||
return this;
|
||||
}
|
||||
|
||||
withCommits(commits: Array<{ oid: string; messageHeadline: string; committedDate: string }>): this {
|
||||
this.context.commits = commits;
|
||||
return this;
|
||||
}
|
||||
|
||||
build(): PRContext {
|
||||
return createMockPRContext(this.context);
|
||||
}
|
||||
}
|
||||
@@ -1,329 +0,0 @@
|
||||
/**
|
||||
* PR Review Fixtures
|
||||
* ==================
|
||||
*
|
||||
* Pre-built PR context fixtures for testing different scenarios.
|
||||
* Use these to quickly set up realistic test data.
|
||||
*/
|
||||
|
||||
import type { PRContext, ChangedFile, AIBotComment } from '../../main/ai/runners/github/pr-review-engine';
|
||||
|
||||
/**
|
||||
* Simple PR fixture for basic testing
|
||||
*
|
||||
* Small PR with 5 files and 50 additions.
|
||||
* Good for unit tests and quick sanity checks.
|
||||
*/
|
||||
export const SIMPLE_PR_CONTEXT: PRContext = {
|
||||
prNumber: 42,
|
||||
title: 'Fix user authentication bug',
|
||||
description: 'Fixes issue where users cannot log in with special characters in password',
|
||||
author: 'testuser',
|
||||
baseBranch: 'develop',
|
||||
headBranch: 'feature/auth-fix',
|
||||
state: 'open',
|
||||
changedFiles: [
|
||||
{
|
||||
path: 'src/auth/login.ts',
|
||||
additions: 15,
|
||||
deletions: 5,
|
||||
status: 'modified',
|
||||
patch: `@@ -10,7 +10,7 @@
|
||||
export function login(username: string, password: string): boolean {
|
||||
- if (!username || !password) return false;
|
||||
+ if (!username || !password || password.length < 8) return false;
|
||||
const hash = hashPassword(password);`
|
||||
},
|
||||
{
|
||||
path: 'src/auth/password-validator.ts',
|
||||
additions: 20,
|
||||
deletions: 0,
|
||||
status: 'added',
|
||||
patch: `@@ -0,0 +1,20 @@
|
||||
+export function validatePassword(password: string): boolean {
|
||||
+ return password.length >= 8 &&
|
||||
+ /[A-Z]/.test(password) &&
|
||||
+ /[0-9]/.test(password);
|
||||
+}`
|
||||
},
|
||||
{
|
||||
path: 'tests/auth/login.test.ts',
|
||||
additions: 10,
|
||||
deletions: 2,
|
||||
status: 'modified',
|
||||
patch: ''
|
||||
},
|
||||
{
|
||||
path: 'README.md',
|
||||
additions: 5,
|
||||
deletions: 0,
|
||||
status: 'modified',
|
||||
patch: ''
|
||||
}
|
||||
] as ChangedFile[],
|
||||
diff: `diff --git a/src/auth/login.ts b/src/auth/login.ts
|
||||
index 1234567..abcdefg 100644
|
||||
--- a/src/auth/login.ts
|
||||
+++ b/src/auth/login.ts
|
||||
@@ -10,7 +10,7 @@
|
||||
export function login(username: string, password: string): boolean {
|
||||
- if (!username || !password) return false;
|
||||
+ if (!username || !password || password.length < 8) return false;
|
||||
const hash = hashPassword(password);
|
||||
`,
|
||||
diffTruncated: false,
|
||||
repoStructure: `
|
||||
src/
|
||||
├── auth/
|
||||
│ ├── login.ts
|
||||
│ ├── password-validator.ts
|
||||
│ └── session.ts
|
||||
├── utils/
|
||||
│ └── hash.ts
|
||||
└── tests/
|
||||
└── auth/
|
||||
└── login.test.ts
|
||||
`,
|
||||
relatedFiles: ['src/auth/session.ts', 'src/utils/hash.ts'],
|
||||
commits: [
|
||||
{
|
||||
oid: 'abc123def456',
|
||||
messageHeadline: 'Fix password validation',
|
||||
committedDate: '2025-01-15T10:00:00Z'
|
||||
},
|
||||
{
|
||||
oid: '789ghi012jkl',
|
||||
messageHeadline: 'Add unit tests',
|
||||
committedDate: '2025-01-15T11:00:00Z'
|
||||
}
|
||||
],
|
||||
labels: ['bug', 'authentication', 'priority-high'],
|
||||
totalAdditions: 50,
|
||||
totalDeletions: 7,
|
||||
aiBotComments: []
|
||||
};
|
||||
|
||||
/**
|
||||
* Complex PR fixture for comprehensive testing
|
||||
*
|
||||
* Large PR with 100 files and 5000 additions.
|
||||
* Tests truncation, pagination, and performance handling.
|
||||
*/
|
||||
export const COMPLEX_PR_CONTEXT: PRContext = {
|
||||
prNumber: 123,
|
||||
title: 'Refactor payment processing system',
|
||||
description: 'Complete overhaul of payment processing with new provider integration',
|
||||
author: 'senior-dev',
|
||||
baseBranch: 'main',
|
||||
headBranch: 'feature/payment-refactor',
|
||||
state: 'open',
|
||||
changedFiles: Array.from({ length: 100 }, (_, i) => ({
|
||||
path: `src/payment/module-${i}.ts`,
|
||||
additions: 50,
|
||||
deletions: 10,
|
||||
status: i < 10 ? 'modified' : i < 50 ? 'added' : 'deleted',
|
||||
patch: i < 20 ? `@@ -1,5 +1,10 @@
|
||||
export class PaymentModule${i} {
|
||||
- constructor() {}
|
||||
+ constructor(private config: PaymentConfig) {}
|
||||
+ process(amount: number): Promise<boolean> {
|
||||
+ return this.provider.charge(amount);
|
||||
+ }
|
||||
}` : ''
|
||||
})) as ChangedFile[],
|
||||
diff: `[DIFF TRUNCATED - Too large to display]
|
||||
Total: 5000 additions, 1000 deletions across 100 files
|
||||
First 20 files shown in patches above...`,
|
||||
diffTruncated: true,
|
||||
repoStructure: `
|
||||
src/
|
||||
├── payment/
|
||||
│ ├── modules/ (90 files)
|
||||
│ ├── providers/
|
||||
│ ├── validators/
|
||||
│ └── utils/
|
||||
├── billing/
|
||||
│ ├── invoices/
|
||||
│ └── subscriptions/
|
||||
└── tests/
|
||||
├── integration/
|
||||
└── unit/
|
||||
`,
|
||||
relatedFiles: [
|
||||
'src/payment/providers/stripe.ts',
|
||||
'src/billing/invoices/generator.ts',
|
||||
'src/payment/validators/card.ts'
|
||||
],
|
||||
commits: Array.from({ length: 25 }, (_, i) => ({
|
||||
oid: `commit${i}`,
|
||||
messageHeadline: `Commit ${i + 1}: Payment module updates`,
|
||||
committedDate: new Date(Date.now() - i * 3600000).toISOString()
|
||||
})),
|
||||
labels: ['refactor', 'payment', 'breaking-change', 'requires-testing'],
|
||||
totalAdditions: 5000,
|
||||
totalDeletions: 1000,
|
||||
aiBotComments: []
|
||||
};
|
||||
|
||||
/**
|
||||
* PR with security vulnerability fixture
|
||||
*
|
||||
* Contains intentional security issues for testing detection.
|
||||
*/
|
||||
export const PR_WITH_SECURITY_ISSUE: PRContext = {
|
||||
prNumber: 456,
|
||||
title: 'Add API key configuration',
|
||||
description: 'Add environment variable support for API keys',
|
||||
author: 'junior-dev',
|
||||
baseBranch: 'develop',
|
||||
headBranch: 'feature/api-config',
|
||||
state: 'open',
|
||||
changedFiles: [
|
||||
{
|
||||
path: 'src/config/api.ts',
|
||||
additions: 8,
|
||||
deletions: 0,
|
||||
status: 'added',
|
||||
patch: `@@ -0,0 +1,8 @@
|
||||
+export const API_KEY = '<REDACTED_API_KEY>';
|
||||
+export const API_SECRET = '<REDACTED_SECRET>';
|
||||
+
|
||||
+export async function fetchAPI(endpoint: string) {
|
||||
+ const response = await fetch(endpoint, {
|
||||
+ headers: { 'Authorization': \`Bearer \${API_KEY}\` }
|
||||
+ });
|
||||
+ return response.json();
|
||||
+}`
|
||||
},
|
||||
{
|
||||
path: 'src/database/query.ts',
|
||||
additions: 12,
|
||||
deletions: 0,
|
||||
status: 'added',
|
||||
patch: `@@ -0,0 +1,12 @@
|
||||
+export async function getUserById(id: string) {
|
||||
+ const query = \`SELECT * FROM users WHERE id = '\${id}'\`;
|
||||
+ return db.execute(query);
|
||||
+}
|
||||
+
|
||||
+export async function searchUsers(term: string) {
|
||||
+ const query = \`SELECT * FROM users WHERE name LIKE '%\${term}%'\`;
|
||||
+ return db.execute(query);
|
||||
+}`
|
||||
},
|
||||
{
|
||||
path: 'src/auth/session.ts',
|
||||
additions: 5,
|
||||
deletions: 0,
|
||||
status: 'added',
|
||||
patch: `@@ -0,0 +1,5 @@
|
||||
+export function createSession(userId: string) {
|
||||
+ const token = btoa(userId + ':' + Date.now());
|
||||
+ localStorage.setItem('session', token);
|
||||
+ return token;
|
||||
+}`
|
||||
}
|
||||
] as ChangedFile[],
|
||||
diff: `diff --git a/src/config/api.ts b/src/config/api.ts
|
||||
new file mode 100644
|
||||
index 0000000..1111111
|
||||
--- /dev/null
|
||||
+++ b/src/config/api.ts
|
||||
@@ -0,0 +1,8 @@
|
||||
+export const API_KEY = '<REDACTED_API_KEY>';
|
||||
+
|
||||
diff --git a/src/database/query.ts b/src/database/query.ts
|
||||
new file mode 100644
|
||||
index 0000000..2222222
|
||||
--- /dev/null
|
||||
+++ b/src/database/query.ts
|
||||
@@ -0,0 +1,12 @@
|
||||
+export async function getUserById(id: string) {
|
||||
+ const query = \`SELECT * FROM users WHERE id = '\${id}'\`;
|
||||
+`,
|
||||
diffTruncated: false,
|
||||
repoStructure: `
|
||||
src/
|
||||
├── config/
|
||||
│ └── api.ts
|
||||
├── database/
|
||||
│ └── query.ts
|
||||
└── auth/
|
||||
└── session.ts
|
||||
`,
|
||||
relatedFiles: [],
|
||||
commits: [
|
||||
{
|
||||
oid: 'abc123',
|
||||
messageHeadline: 'Add API configuration',
|
||||
committedDate: '2025-01-15T10:00:00Z'
|
||||
}
|
||||
],
|
||||
labels: ['feature', 'api'],
|
||||
totalAdditions: 25,
|
||||
totalDeletions: 0,
|
||||
aiBotComments: []
|
||||
};
|
||||
|
||||
/**
|
||||
* PR with AI bot comments for triage testing
|
||||
*
|
||||
* Contains comments from various AI review tools.
|
||||
*/
|
||||
export const PR_WITH_AI_COMMENTS: PRContext = {
|
||||
...SIMPLE_PR_CONTEXT,
|
||||
prNumber: 789,
|
||||
aiBotComments: [
|
||||
{
|
||||
commentId: 1,
|
||||
author: 'coderabbitai',
|
||||
toolName: 'CodeRabbit',
|
||||
body: 'Consider adding error handling for the login function. What happens if hashPassword throws?',
|
||||
file: 'src/auth/login.ts',
|
||||
line: 12,
|
||||
createdAt: '2025-01-15T10:30:00Z'
|
||||
},
|
||||
{
|
||||
commentId: 2,
|
||||
author: 'cursor-agent',
|
||||
toolName: 'Cursor',
|
||||
body: 'The password validation logic is good but could be more strict. Consider requiring special characters.',
|
||||
file: 'src/auth/password-validator.ts',
|
||||
line: 3,
|
||||
createdAt: '2025-01-15T10:35:00Z'
|
||||
},
|
||||
{
|
||||
commentId: 3,
|
||||
author: 'greptile',
|
||||
toolName: 'Greptile',
|
||||
body: 'Minor nit: add JSDoc comments for public functions',
|
||||
file: 'src/auth/login.ts',
|
||||
createdAt: '2025-01-15T10:40:00Z'
|
||||
}
|
||||
] as AIBotComment[]
|
||||
};
|
||||
|
||||
/**
|
||||
* Empty PR fixture for edge case testing
|
||||
*
|
||||
* Minimal PR with no changes (good for error testing).
|
||||
*/
|
||||
export const EMPTY_PR_CONTEXT: PRContext = {
|
||||
prNumber: 999,
|
||||
title: 'Documentation update',
|
||||
description: 'Update README with new examples',
|
||||
author: 'doc-writer',
|
||||
baseBranch: 'main',
|
||||
headBranch: 'docs/update-readme',
|
||||
state: 'open',
|
||||
changedFiles: [] as ChangedFile[],
|
||||
diff: '',
|
||||
diffTruncated: false,
|
||||
repoStructure: '',
|
||||
relatedFiles: [],
|
||||
commits: [],
|
||||
labels: ['documentation'],
|
||||
totalAdditions: 0,
|
||||
totalDeletions: 0,
|
||||
aiBotComments: []
|
||||
};
|
||||
@@ -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