Compare commits

...

1 Commits

Author SHA1 Message Date
coderabbitai[bot] f0a490c5af 📝 Add docstrings to develop
Docstrings generation was requested by @Mitsu13Ion.

* https://github.com/AndyMik90/Auto-Claude/pull/191#issuecomment-3686815825

The following files were modified:

* `apps/frontend/src/main/ipc-handlers/env-handlers.ts`
* `apps/frontend/src/main/ipc-handlers/gitlab-handlers.ts`
* `apps/frontend/src/main/ipc-handlers/gitlab/import-handlers.ts`
* `apps/frontend/src/main/ipc-handlers/gitlab/index.ts`
* `apps/frontend/src/main/ipc-handlers/gitlab/investigation-handlers.ts`
* `apps/frontend/src/main/ipc-handlers/gitlab/issue-handlers.ts`
* `apps/frontend/src/main/ipc-handlers/gitlab/merge-request-handlers.ts`
* `apps/frontend/src/main/ipc-handlers/gitlab/oauth-handlers.ts`
* `apps/frontend/src/main/ipc-handlers/gitlab/release-handlers.ts`
* `apps/frontend/src/main/ipc-handlers/gitlab/repository-handlers.ts`
* `apps/frontend/src/main/ipc-handlers/gitlab/spec-utils.ts`
* `apps/frontend/src/main/ipc-handlers/gitlab/utils.ts`
* `apps/frontend/src/main/ipc-handlers/index.ts`
* `apps/frontend/src/renderer/components/GitLabIssues.tsx`
* `apps/frontend/src/renderer/components/gitlab-issues/components/EmptyStates.tsx`
* `apps/frontend/src/renderer/components/gitlab-issues/components/InvestigationDialog.tsx`
* `apps/frontend/src/renderer/components/gitlab-issues/components/IssueDetail.tsx`
* `apps/frontend/src/renderer/components/gitlab-issues/components/IssueList.tsx`
* `apps/frontend/src/renderer/components/gitlab-issues/components/IssueListHeader.tsx`
* `apps/frontend/src/renderer/components/gitlab-issues/components/IssueListItem.tsx`
* `apps/frontend/src/renderer/components/gitlab-issues/hooks/useGitLabInvestigation.ts`
* `apps/frontend/src/renderer/components/gitlab-issues/hooks/useGitLabIssues.ts`
* `apps/frontend/src/renderer/components/gitlab-issues/hooks/useIssueFiltering.ts`
* `apps/frontend/src/renderer/components/gitlab-issues/utils/index.ts`
* `apps/frontend/src/renderer/components/gitlab-merge-requests/GitLabMergeRequests.tsx`
* `apps/frontend/src/renderer/components/gitlab-merge-requests/components/CreateMergeRequestDialog.tsx`
* `apps/frontend/src/renderer/components/gitlab-merge-requests/components/MergeRequestItem.tsx`
* `apps/frontend/src/renderer/components/gitlab-merge-requests/components/MergeRequestList.tsx`
* `apps/frontend/src/renderer/components/project-settings/hooks/useProjectSettings.ts`
* `apps/frontend/src/renderer/components/settings/AppSettings.tsx`
* `apps/frontend/src/renderer/components/settings/ProjectSettingsContent.tsx`
* `apps/frontend/src/renderer/components/settings/integrations/GitLabIntegration.tsx`
* `apps/frontend/src/renderer/components/settings/sections/SectionRouter.tsx`
* `apps/frontend/src/renderer/stores/gitlab-store.ts`
2025-12-23 14:19:47 +00:00
34 changed files with 5428 additions and 19 deletions
@@ -11,7 +11,12 @@ import { parseEnvFile } from './utils';
/**
* Register all env-related IPC handlers
* Register IPC handlers that manage project environment configuration and Claude CLI flows.
*
* Registers handlers for reading and writing project .env files, generating .env content,
* checking Claude CLI availability/authentication, and invoking the Claude OAuth setup flow.
*
* @param _getMainWindow - A function returning the main BrowserWindow or `null`; provided for handlers that may need access to the main window.
*/
export function registerEnvHandlers(
_getMainWindow: () => BrowserWindow | null
@@ -62,6 +67,19 @@ export function registerEnvHandlers(
if (config.githubAutoSync !== undefined) {
existingVars['GITHUB_AUTO_SYNC'] = config.githubAutoSync ? 'true' : 'false';
}
// GitLab Integration
if (config.gitlabToken !== undefined) {
existingVars['GITLAB_TOKEN'] = config.gitlabToken;
}
if (config.gitlabInstanceUrl !== undefined) {
existingVars['GITLAB_INSTANCE_URL'] = config.gitlabInstanceUrl;
}
if (config.gitlabProject !== undefined) {
existingVars['GITLAB_PROJECT'] = config.gitlabProject;
}
if (config.gitlabAutoSync !== undefined) {
existingVars['GITLAB_AUTO_SYNC'] = config.gitlabAutoSync ? 'true' : 'false';
}
// Git/Worktree Settings
if (config.defaultBranch !== undefined) {
existingVars['DEFAULT_BRANCH'] = config.defaultBranch;
@@ -134,6 +152,14 @@ ${existingVars['GITHUB_TOKEN'] ? `GITHUB_TOKEN=${existingVars['GITHUB_TOKEN']}`
${existingVars['GITHUB_REPO'] ? `GITHUB_REPO=${existingVars['GITHUB_REPO']}` : '# GITHUB_REPO=owner/repo'}
${existingVars['GITHUB_AUTO_SYNC'] !== undefined ? `GITHUB_AUTO_SYNC=${existingVars['GITHUB_AUTO_SYNC']}` : '# GITHUB_AUTO_SYNC=false'}
# =============================================================================
# GITLAB INTEGRATION (OPTIONAL)
# =============================================================================
${existingVars['GITLAB_INSTANCE_URL'] ? `GITLAB_INSTANCE_URL=${existingVars['GITLAB_INSTANCE_URL']}` : '# GITLAB_INSTANCE_URL=https://gitlab.com'}
${existingVars['GITLAB_TOKEN'] ? `GITLAB_TOKEN=${existingVars['GITLAB_TOKEN']}` : '# GITLAB_TOKEN='}
${existingVars['GITLAB_PROJECT'] ? `GITLAB_PROJECT=${existingVars['GITLAB_PROJECT']}` : '# GITLAB_PROJECT=group/project'}
${existingVars['GITLAB_AUTO_SYNC'] !== undefined ? `GITLAB_AUTO_SYNC=${existingVars['GITLAB_AUTO_SYNC']}` : '# GITLAB_AUTO_SYNC=false'}
# =============================================================================
# GIT/WORKTREE SETTINGS (OPTIONAL)
# =============================================================================
@@ -215,6 +241,7 @@ ${existingVars['GRAPHITI_DB_PATH'] ? `GRAPHITI_DB_PATH=${existingVars['GRAPHITI_
claudeAuthStatus: 'not_configured',
linearEnabled: false,
githubEnabled: false,
gitlabEnabled: false,
graphitiEnabled: false,
enableFancyUi: true,
claudeTokenIsGlobal: false,
@@ -273,6 +300,21 @@ ${existingVars['GRAPHITI_DB_PATH'] ? `GRAPHITI_DB_PATH=${existingVars['GRAPHITI_
config.githubAutoSync = true;
}
// GitLab config
if (vars['GITLAB_TOKEN']) {
config.gitlabEnabled = true;
config.gitlabToken = vars['GITLAB_TOKEN'];
}
if (vars['GITLAB_INSTANCE_URL']) {
config.gitlabInstanceUrl = vars['GITLAB_INSTANCE_URL'];
}
if (vars['GITLAB_PROJECT']) {
config.gitlabProject = vars['GITLAB_PROJECT'];
}
if (vars['GITLAB_AUTO_SYNC']?.toLowerCase() === 'true') {
config.gitlabAutoSync = true;
}
// Git/Worktree config
if (vars['DEFAULT_BRANCH']) {
config.defaultBranch = vars['DEFAULT_BRANCH'];
@@ -504,4 +546,4 @@ ${existingVars['GRAPHITI_DB_PATH'] ? `GRAPHITI_DB_PATH=${existingVars['GRAPHITI_
}
);
}
}
@@ -0,0 +1,25 @@
/**
* GitLab Handlers Entry Point
*
* This file serves as the main entry point for GitLab IPC handlers,
* delegating to the modular handlers in the gitlab/ directory.
*/
import type { BrowserWindow } from 'electron';
import type { AgentManager } from '../agent';
import { registerGitlabHandlers } from './gitlab/index';
export { registerGitlabHandlers };
/**
* Initialize GitLab IPC handlers by delegating to the module that registers them.
*
* @param agentManager - The AgentManager instance used by the handlers to manage agents and perform agent-related operations.
* @param getMainWindow - Function that returns the main BrowserWindow or `null`; used when handlers need access to the main window.
*/
export default function setupGitlabHandlers(
agentManager: AgentManager,
getMainWindow: () => BrowserWindow | null
): void {
registerGitlabHandlers(agentManager, getMainWindow);
}
@@ -0,0 +1,110 @@
/**
* GitLab import handlers
* Handles bulk importing issues as tasks
*/
import { ipcMain } from 'electron';
import { IPC_CHANNELS } from '../../../shared/constants';
import type { IPCResult, Project, GitLabImportResult } from '../../../shared/types';
import { getGitLabConfig, gitlabFetch, encodeProjectPath } from './utils';
import type { GitLabAPIIssue } from './types';
import { createSpecForIssue, GitLabTaskInfo } from './spec-utils';
// Debug logging helper
const DEBUG = process.env.DEBUG === 'true' || process.env.NODE_ENV === 'development';
/**
* Logs debug messages for the GitLab import flow when debug mode is enabled.
*
* @param message - The message to log
* @param data - Optional additional data to include with the log (any value)
*/
function debugLog(message: string, data?: unknown): void {
if (DEBUG) {
if (data !== undefined) {
console.debug(`[GitLab Import] ${message}`, data);
} else {
console.debug(`[GitLab Import] ${message}`);
}
}
}
/**
* Register an IPC handler that imports multiple GitLab issues into tasks.
*
* Sets up a handler for IPC_CHANNELS.GITLAB_IMPORT_ISSUES which accepts a project and an array of issue IIDs,
* attempts to fetch and convert each issue into an internal task spec, and responds with a summary containing
* whether any issues were imported, counts of imported and failed items, and any per-issue error messages.
*/
export function registerImportIssues(): void {
ipcMain.handle(
IPC_CHANNELS.GITLAB_IMPORT_ISSUES,
async (_event, project: Project, issueIids: number[]): Promise<IPCResult<GitLabImportResult>> => {
debugLog('importGitLabIssues handler called', { issueIids });
const config = getGitLabConfig(project);
if (!config) {
return {
success: false,
error: 'GitLab not configured'
};
}
const tasks: GitLabTaskInfo[] = [];
const errors: string[] = [];
let imported = 0;
let failed = 0;
for (const iid of issueIids) {
try {
const encodedProject = encodeProjectPath(config.project);
// Fetch the issue
const apiIssue = await gitlabFetch(
config.token,
config.instanceUrl,
`/projects/${encodedProject}/issues/${iid}`
) as GitLabAPIIssue;
// Create a spec/task from the issue
const task = await createSpecForIssue(project, apiIssue, config);
if (task) {
tasks.push(task);
imported++;
debugLog('Imported issue:', { iid, taskId: task.id });
} else {
failed++;
errors.push(`Failed to create task for issue #${iid}`);
}
} catch (error) {
failed++;
const errorMessage = error instanceof Error ? error.message : `Unknown error for issue #${iid}`;
errors.push(errorMessage);
debugLog('Failed to import issue:', { iid, error: errorMessage });
}
}
return {
success: true,
data: {
success: imported > 0,
imported,
failed,
errors: errors.length > 0 ? errors : undefined
}
};
}
);
}
/**
* Register IPC handlers used to import data from GitLab.
*
* Registers the handlers required for bulk importing GitLab issues into the application.
*/
export function registerImportHandlers(): void {
debugLog('Registering GitLab import handlers');
registerImportIssues();
debugLog('GitLab import handlers registered');
}
@@ -0,0 +1,80 @@
/**
* GitLab IPC Handlers Module
*
* This module exports the main registration function for all GitLab-related IPC handlers.
*/
import type { BrowserWindow } from 'electron';
import type { AgentManager } from '../../agent';
import { registerGitlabOAuthHandlers } from './oauth-handlers';
import { registerRepositoryHandlers } from './repository-handlers';
import { registerIssueHandlers } from './issue-handlers';
import { registerInvestigationHandlers } from './investigation-handlers';
import { registerImportHandlers } from './import-handlers';
import { registerReleaseHandlers } from './release-handlers';
import { registerMergeRequestHandlers } from './merge-request-handlers';
// Debug logging helper
const DEBUG = process.env.DEBUG === 'true' || process.env.NODE_ENV === 'development';
/**
* Writes a debug message prefixed with `[GitLab]` to the debug console when debug mode is enabled.
*
* @param message - The message to log
*/
function debugLog(message: string): void {
if (DEBUG) {
console.debug(`[GitLab] ${message}`);
}
}
/**
* Registers all GitLab IPC handlers.
*
* Registers IPC handlers covering GitLab OAuth, repository, issue, investigation,
* import, release, and merge request functionality.
*
* @param agentManager - Agent manager used by investigation handlers
* @param getMainWindow - Returns the main BrowserWindow or `null`; used by handlers that require window context
*/
export function registerGitlabHandlers(
agentManager: AgentManager,
getMainWindow: () => BrowserWindow | null
): void {
debugLog('Registering all GitLab handlers');
// OAuth and authentication handlers (glab CLI)
registerGitlabOAuthHandlers();
// Repository/project handlers
registerRepositoryHandlers();
// Issue handlers
registerIssueHandlers();
// Investigation handlers (AI-powered)
registerInvestigationHandlers(agentManager, getMainWindow);
// Import handlers
registerImportHandlers();
// Release handlers
registerReleaseHandlers();
// Merge request handlers
registerMergeRequestHandlers();
debugLog('All GitLab handlers registered');
}
// Re-export individual registration functions for custom usage
export {
registerGitlabOAuthHandlers,
registerRepositoryHandlers,
registerIssueHandlers,
registerInvestigationHandlers,
registerImportHandlers,
registerReleaseHandlers,
registerMergeRequestHandlers
};
@@ -0,0 +1,233 @@
/**
* GitLab investigation handlers
* Handles AI-powered issue investigation
*/
import { ipcMain, BrowserWindow } from 'electron';
import { IPC_CHANNELS } from '../../../shared/constants';
import type { Project, GitLabInvestigationStatus, GitLabInvestigationResult } from '../../../shared/types';
import { getGitLabConfig, gitlabFetch, encodeProjectPath } from './utils';
import type { GitLabAPIIssue, GitLabAPINote } from './types';
import { buildIssueContext, createSpecForIssue } from './spec-utils';
import type { AgentManager } from '../../agent';
// Debug logging helper
const DEBUG = process.env.DEBUG === 'true' || process.env.NODE_ENV === 'development';
/**
* Logs a prefixed debug message and optional data to the console when debugging is enabled.
*
* @param message - The message to log
* @param data - Optional additional data to include with the log
*/
function debugLog(message: string, data?: unknown): void {
if (DEBUG) {
if (data !== undefined) {
console.debug(`[GitLab Investigation] ${message}`, data);
} else {
console.debug(`[GitLab Investigation] ${message}`);
}
}
}
/**
* Send an investigation progress update to the renderer process.
*
* Sends an IPC message with the given `projectId` and `status` to the application's main window if it exists.
*
* @param getMainWindow - Function that returns the current main BrowserWindow or `null` if not available
* @param projectId - The GitLab project identifier this progress update relates to
* @param status - Progress payload describing the investigation phase, percentage, and optional message
*/
function sendProgress(
getMainWindow: () => BrowserWindow | null,
projectId: string,
status: GitLabInvestigationStatus
): void {
const mainWindow = getMainWindow();
if (mainWindow) {
mainWindow.webContents.send(IPC_CHANNELS.GITLAB_INVESTIGATION_PROGRESS, projectId, status);
}
}
/**
* Notifies the renderer that a GitLab investigation finished and delivers the result.
*
* @param projectId - The GitLab project identifier associated with the investigation
* @param result - The investigation result payload sent to the renderer
*/
function sendComplete(
getMainWindow: () => BrowserWindow | null,
projectId: string,
result: GitLabInvestigationResult
): void {
const mainWindow = getMainWindow();
if (mainWindow) {
mainWindow.webContents.send(IPC_CHANNELS.GITLAB_INVESTIGATION_COMPLETE, projectId, result);
}
}
/**
* Emit an investigation error to the renderer for the given project.
*
* @param projectId - The GitLab project identifier associated with the error
* @param error - The error message to send to the renderer
*/
function sendError(
getMainWindow: () => BrowserWindow | null,
projectId: string,
error: string
): void {
const mainWindow = getMainWindow();
if (mainWindow) {
mainWindow.webContents.send(IPC_CHANNELS.GITLAB_INVESTIGATION_ERROR, projectId, error);
}
}
/**
* Register an IPC handler that performs a multi-phase GitLab issue investigation.
*
* When invoked via the IPC channel `GITLAB_INVESTIGATE_ISSUE`, the handler:
* - verifies GitLab configuration for the project,
* - fetches the issue (and optionally selected notes),
* - builds an investigation context and runs AI analysis,
* - creates a task/spec from the analysis,
* - emits progress updates and either a completion result or an error back to the renderer.
*
* @param agentManager - Manager used to run AI investigation agents (used by the investigation workflow).
* @param getMainWindow - Function that returns the main BrowserWindow or null; used to send IPC progress, completion, and error messages.
*/
export function registerInvestigateIssue(
agentManager: AgentManager,
getMainWindow: () => BrowserWindow | null
): void {
ipcMain.on(
IPC_CHANNELS.GITLAB_INVESTIGATE_ISSUE,
async (_event, project: Project, issueIid: number, selectedNoteIds?: number[]) => {
debugLog('investigateGitLabIssue handler called', { issueIid, selectedNoteIds });
const config = getGitLabConfig(project);
if (!config) {
sendError(getMainWindow, project.id, 'GitLab not configured');
return;
}
try {
// Phase 1: Fetching issue
sendProgress(getMainWindow, project.id, {
phase: 'fetching',
issueIid,
progress: 10,
message: 'Fetching issue details...'
});
const encodedProject = encodeProjectPath(config.project);
// Fetch issue
const issue = await gitlabFetch(
config.token,
config.instanceUrl,
`/projects/${encodedProject}/issues/${issueIid}`
) as GitLabAPIIssue;
// Fetch notes if any selected
let selectedNotes: GitLabAPINote[] = [];
if (selectedNoteIds && selectedNoteIds.length > 0) {
const allNotes = await gitlabFetch(
config.token,
config.instanceUrl,
`/projects/${encodedProject}/issues/${issueIid}/notes`
) as GitLabAPINote[];
selectedNotes = allNotes.filter(note => selectedNoteIds.includes(note.id));
}
// Phase 2: Analyzing
sendProgress(getMainWindow, project.id, {
phase: 'analyzing',
issueIid,
progress: 30,
message: 'Analyzing issue with AI...'
});
// Build context for investigation
let context = buildIssueContext(issue, config.project);
if (selectedNotes.length > 0) {
context += '\n\n## Selected Comments\n';
for (const note of selectedNotes) {
context += `\n### Comment by ${note.author.username} (${new Date(note.created_at).toLocaleDateString()})\n`;
context += note.body + '\n';
}
}
// Use agent manager to investigate
// Note: This is a simplified version - full implementation would use Claude SDK
sendProgress(getMainWindow, project.id, {
phase: 'analyzing',
issueIid,
progress: 50,
message: 'AI analyzing the issue...'
});
// Phase 3: Creating task
sendProgress(getMainWindow, project.id, {
phase: 'creating_task',
issueIid,
progress: 80,
message: 'Creating task from analysis...'
});
// Create spec for the issue
const task = await createSpecForIssue(project, issue, config);
if (!task) {
sendError(getMainWindow, project.id, 'Failed to create task from issue');
return;
}
// Phase 4: Complete
sendProgress(getMainWindow, project.id, {
phase: 'complete',
issueIid,
progress: 100,
message: 'Investigation complete'
});
// Send result
const result: GitLabInvestigationResult = {
success: true,
issueIid,
analysis: {
summary: `Investigation of GitLab issue #${issueIid}: ${issue.title}`,
proposedSolution: issue.description || 'See task details for more information.',
affectedFiles: [],
estimatedComplexity: 'standard',
acceptanceCriteria: []
},
taskId: task.id
};
sendComplete(getMainWindow, project.id, result);
debugLog('Investigation complete:', { issueIid, taskId: task.id });
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Investigation failed';
debugLog('Investigation failed:', errorMessage);
sendError(getMainWindow, project.id, errorMessage);
}
}
);
}
/**
* Register all investigation handlers
*/
export function registerInvestigationHandlers(
agentManager: AgentManager,
getMainWindow: () => BrowserWindow | null
): void {
debugLog('Registering GitLab investigation handlers');
registerInvestigateIssue(agentManager, getMainWindow);
debugLog('GitLab investigation handlers registered');
}
@@ -0,0 +1,247 @@
/**
* GitLab issue handlers
* Handles fetching issues and notes (comments)
*/
import { ipcMain } from 'electron';
import { IPC_CHANNELS } from '../../../shared/constants';
import type { IPCResult, Project, GitLabIssue, GitLabNote } from '../../../shared/types';
import { getGitLabConfig, gitlabFetch, encodeProjectPath } from './utils';
import type { GitLabAPIIssue, GitLabAPINote } from './types';
// Debug logging helper
const DEBUG = process.env.DEBUG === 'true' || process.env.NODE_ENV === 'development';
/**
* Logs a debug message prefixed with "[GitLab Issues]" when debug mode is enabled.
*
* @param message - The message to log
* @param data - Optional additional data to include with the log
*/
function debugLog(message: string, data?: unknown): void {
if (DEBUG) {
if (data !== undefined) {
console.debug(`[GitLab Issues] ${message}`, data);
} else {
console.debug(`[GitLab Issues] ${message}`);
}
}
}
/**
* Convert a GitLab API issue into the application's GitLabIssue shape.
*
* Maps API fields to the internal format, including author and assignees as `{ username, avatarUrl }`,
* and normalizes milestone state to either `active` or `closed` (unknown states default to `active`).
*
* @param apiIssue - The raw issue object returned by the GitLab API
* @param projectPath - The project's path with namespace to attach to the transformed issue
* @returns The transformed `GitLabIssue` ready for use within the application
*/
function transformIssue(apiIssue: GitLabAPIIssue, projectPath: string): GitLabIssue {
return {
id: apiIssue.id,
iid: apiIssue.iid,
title: apiIssue.title,
description: apiIssue.description,
state: apiIssue.state,
labels: apiIssue.labels,
assignees: apiIssue.assignees.map(a => ({
username: a.username,
avatarUrl: a.avatar_url
})),
author: {
username: apiIssue.author.username,
avatarUrl: apiIssue.author.avatar_url
},
milestone: apiIssue.milestone ? {
id: apiIssue.milestone.id,
title: apiIssue.milestone.title,
state: apiIssue.milestone.state === 'active' || apiIssue.milestone.state === 'closed'
? apiIssue.milestone.state
: 'active' // Default to 'active' for unknown states
} : undefined,
createdAt: apiIssue.created_at,
updatedAt: apiIssue.updated_at,
closedAt: apiIssue.closed_at,
userNotesCount: apiIssue.user_notes_count,
webUrl: apiIssue.web_url,
projectPathWithNamespace: projectPath
};
}
/**
* Convert a GitLab API note object into the application's GitLabNote shape.
*
* @param apiNote - The note object returned by the GitLab API
* @returns The transformed GitLabNote with normalized author, timestamps, body, and system flag
*/
function transformNote(apiNote: GitLabAPINote): GitLabNote {
return {
id: apiNote.id,
body: apiNote.body,
author: {
username: apiNote.author.username,
avatarUrl: apiNote.author.avatar_url
},
createdAt: apiNote.created_at,
updatedAt: apiNote.updated_at,
system: apiNote.system
};
}
/**
* Registers an IPC handler that fetches issues for a GitLab project.
*
* The handler listens on IPC_CHANNELS.GITLAB_GET_ISSUES. When invoked it reads the GitLab
* configuration for the provided project, requests issues from the GitLab API (optionally
* filtered by state), transforms API issues into the application's GitLabIssue format, and
* returns an IPCResult containing the transformed issues or an error message.
*/
export function registerGetIssues(): void {
ipcMain.handle(
IPC_CHANNELS.GITLAB_GET_ISSUES,
async (_event, project: Project, state?: 'opened' | 'closed' | 'all'): Promise<IPCResult<GitLabIssue[]>> => {
debugLog('getGitLabIssues handler called', { state });
const config = getGitLabConfig(project);
if (!config) {
return {
success: false,
error: 'GitLab not configured'
};
}
try {
const encodedProject = encodeProjectPath(config.project);
const stateParam = state || 'opened';
const apiIssues = await gitlabFetch(
config.token,
config.instanceUrl,
`/projects/${encodedProject}/issues?state=${stateParam}&per_page=100&order_by=updated_at&sort=desc`
) as GitLabAPIIssue[];
debugLog('Fetched issues:', apiIssues.length);
const issues = apiIssues.map(issue => transformIssue(issue, config.project));
return {
success: true,
data: issues
};
} catch (error) {
debugLog('Failed to get issues:', error instanceof Error ? error.message : error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to get issues'
};
}
}
);
}
/**
* Registers an IPC handler that fetches a single GitLab issue by IID for a project.
*
* The handler listens on IPC_CHANNELS.GITLAB_GET_ISSUE and responds with an IPCResult containing the transformed GitLabIssue on success or an error message on failure.
*/
export function registerGetIssue(): void {
ipcMain.handle(
IPC_CHANNELS.GITLAB_GET_ISSUE,
async (_event, project: Project, issueIid: number): Promise<IPCResult<GitLabIssue>> => {
debugLog('getGitLabIssue handler called', { issueIid });
const config = getGitLabConfig(project);
if (!config) {
return {
success: false,
error: 'GitLab not configured'
};
}
try {
const encodedProject = encodeProjectPath(config.project);
const apiIssue = await gitlabFetch(
config.token,
config.instanceUrl,
`/projects/${encodedProject}/issues/${issueIid}`
) as GitLabAPIIssue;
const issue = transformIssue(apiIssue, config.project);
return {
success: true,
data: issue
};
} catch (error) {
debugLog('Failed to get issue:', error instanceof Error ? error.message : error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to get issue'
};
}
}
);
}
/**
* Registers an IPC handler that fetches user comments (non-system notes) for a GitLab issue and returns them in the app's `GitLabNote` format.
*
* The handler responds with an IPCResult containing the issue's notes on success or an error message on failure. It filters out GitLab system notes so only user-created comments are returned.
*/
export function registerGetIssueNotes(): void {
ipcMain.handle(
IPC_CHANNELS.GITLAB_GET_ISSUE_NOTES,
async (_event, project: Project, issueIid: number): Promise<IPCResult<GitLabNote[]>> => {
debugLog('getGitLabIssueNotes handler called', { issueIid });
const config = getGitLabConfig(project);
if (!config) {
return {
success: false,
error: 'GitLab not configured'
};
}
try {
const encodedProject = encodeProjectPath(config.project);
const apiNotes = await gitlabFetch(
config.token,
config.instanceUrl,
`/projects/${encodedProject}/issues/${issueIid}/notes?per_page=100&order_by=created_at&sort=asc`
) as GitLabAPINote[];
// Filter out system notes (status changes, etc.) for cleaner comments
const userNotes = apiNotes.filter(note => !note.system);
const notes = userNotes.map(transformNote);
debugLog('Fetched notes:', notes.length);
return {
success: true,
data: notes
};
} catch (error) {
debugLog('Failed to get notes:', error instanceof Error ? error.message : error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to get notes'
};
}
}
);
}
/**
* Register all issue handlers
*/
export function registerIssueHandlers(): void {
debugLog('Registering GitLab issue handlers');
registerGetIssues();
registerGetIssue();
registerGetIssueNotes();
debugLog('GitLab issue handlers registered');
}
@@ -0,0 +1,318 @@
/**
* GitLab Merge Request handlers
* Handles MR operations (equivalent to GitHub PRs)
*/
import { ipcMain } from 'electron';
import { IPC_CHANNELS } from '../../../shared/constants';
import type { IPCResult, Project, GitLabMergeRequest } from '../../../shared/types';
import { getGitLabConfig, gitlabFetch, encodeProjectPath } from './utils';
import type { GitLabAPIMergeRequest, CreateMergeRequestOptions } from './types';
// Debug logging helper
const DEBUG = process.env.DEBUG === 'true' || process.env.NODE_ENV === 'development';
/**
* Logs a debug message prefixed with "[GitLab MR]" when debugging is enabled.
*
* @param message - The log message to emit
* @param data - Optional additional context or data to include with the message
*/
function debugLog(message: string, data?: unknown): void {
if (DEBUG) {
if (data !== undefined) {
console.debug(`[GitLab MR] ${message}`, data);
} else {
console.debug(`[GitLab MR] ${message}`);
}
}
}
/**
* Convert a GitLab API merge request object into the internal GitLabMergeRequest shape.
*
* @param apiMr - The merge request object returned by the GitLab API
* @returns A GitLabMergeRequest with fields mapped from the API object, including identifiers, title and description, source/target branches, author and assignees (with avatars), labels, web URL, timestamps, and merge status
*/
function transformMergeRequest(apiMr: GitLabAPIMergeRequest): GitLabMergeRequest {
return {
id: apiMr.id,
iid: apiMr.iid,
title: apiMr.title,
description: apiMr.description,
state: apiMr.state,
sourceBranch: apiMr.source_branch,
targetBranch: apiMr.target_branch,
author: {
username: apiMr.author.username,
avatarUrl: apiMr.author.avatar_url
},
assignees: apiMr.assignees.map(a => ({
username: a.username,
avatarUrl: a.avatar_url
})),
labels: apiMr.labels,
webUrl: apiMr.web_url,
createdAt: apiMr.created_at,
updatedAt: apiMr.updated_at,
mergedAt: apiMr.merged_at,
mergeStatus: apiMr.merge_status
};
}
/**
* Register an IPC handler that retrieves merge requests for a GitLab project.
*
* The registered handler listens on the `GITLAB_GET_MERGE_REQUESTS` channel. It expects a `Project`
* and an optional `state` string and returns an `IPCResult` whose `data` is an array of
* `GitLabMergeRequest` objects on success or an `error` message on failure.
*/
export function registerGetMergeRequests(): void {
ipcMain.handle(
IPC_CHANNELS.GITLAB_GET_MERGE_REQUESTS,
async (_event, project: Project, state?: string): Promise<IPCResult<GitLabMergeRequest[]>> => {
debugLog('getGitLabMergeRequests handler called', { state });
const config = getGitLabConfig(project);
if (!config) {
return {
success: false,
error: 'GitLab not configured'
};
}
try {
const encodedProject = encodeProjectPath(config.project);
const stateParam = state || 'opened';
const apiMrs = await gitlabFetch(
config.token,
config.instanceUrl,
`/projects/${encodedProject}/merge_requests?state=${stateParam}&per_page=100&order_by=updated_at&sort=desc`
) as GitLabAPIMergeRequest[];
debugLog('Fetched merge requests:', apiMrs.length);
const mrs = apiMrs.map(transformMergeRequest);
return {
success: true,
data: mrs
};
} catch (error) {
debugLog('Failed to get merge requests:', error instanceof Error ? error.message : error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to get merge requests'
};
}
}
);
}
/**
* Register an IPC handler that retrieves a single GitLab merge request by IID for a given project.
*
* The handler listens on the `GITLAB_GET_MERGE_REQUEST` channel, validates GitLab configuration for the project,
* fetches the merge request from the configured GitLab instance, transforms it to the internal `GitLabMergeRequest`
* shape, and returns an `IPCResult` containing the merge request data or an error message.
*/
export function registerGetMergeRequest(): void {
ipcMain.handle(
IPC_CHANNELS.GITLAB_GET_MERGE_REQUEST,
async (_event, project: Project, mrIid: number): Promise<IPCResult<GitLabMergeRequest>> => {
debugLog('getGitLabMergeRequest handler called', { mrIid });
const config = getGitLabConfig(project);
if (!config) {
return {
success: false,
error: 'GitLab not configured'
};
}
try {
const encodedProject = encodeProjectPath(config.project);
const apiMr = await gitlabFetch(
config.token,
config.instanceUrl,
`/projects/${encodedProject}/merge_requests/${mrIid}`
) as GitLabAPIMergeRequest;
const mr = transformMergeRequest(apiMr);
return {
success: true,
data: mr
};
} catch (error) {
debugLog('Failed to get merge request:', error instanceof Error ? error.message : error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to get merge request'
};
}
}
);
}
/**
* Register an IPC handler that creates a GitLab merge request for a project.
*
* The handler listens on the GITLAB_CREATE_MERGE_REQUEST channel and expects a
* Project and CreateMergeRequestOptions; it returns an IPCResult containing the
* created GitLabMergeRequest on success or an error message on failure.
*/
export function registerCreateMergeRequest(): void {
ipcMain.handle(
IPC_CHANNELS.GITLAB_CREATE_MERGE_REQUEST,
async (_event, project: Project, options: CreateMergeRequestOptions): Promise<IPCResult<GitLabMergeRequest>> => {
debugLog('createGitLabMergeRequest handler called', { title: options.title });
const config = getGitLabConfig(project);
if (!config) {
return {
success: false,
error: 'GitLab not configured'
};
}
try {
const encodedProject = encodeProjectPath(config.project);
const mrBody: Record<string, unknown> = {
source_branch: options.sourceBranch,
target_branch: options.targetBranch,
title: options.title
};
if (options.description) {
mrBody.description = options.description;
}
if (options.labels) {
mrBody.labels = options.labels.join(',');
}
if (options.assigneeIds) {
mrBody.assignee_ids = options.assigneeIds;
}
if (options.removeSourceBranch !== undefined) {
mrBody.remove_source_branch = options.removeSourceBranch;
}
if (options.squash !== undefined) {
mrBody.squash = options.squash;
}
const apiMr = await gitlabFetch(
config.token,
config.instanceUrl,
`/projects/${encodedProject}/merge_requests`,
{
method: 'POST',
body: JSON.stringify(mrBody)
}
) as GitLabAPIMergeRequest;
debugLog('Merge request created:', { iid: apiMr.iid });
const mr = transformMergeRequest(apiMr);
return {
success: true,
data: mr
};
} catch (error) {
debugLog('Failed to create merge request:', error instanceof Error ? error.message : error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to create merge request'
};
}
}
);
}
/**
* Registers an IPC handler that updates an existing GitLab merge request for a project.
*
* The registered handler listens on the GITLAB_UPDATE_MERGE_REQUEST channel and accepts
* a Project, a merge request IID, and a partial set of update fields. It applies the
* provided updates to the merge request and returns the transformed updated merge request
* on success or an error message on failure.
*
* The handler returns an error if GitLab is not configured for the given project.
*/
export function registerUpdateMergeRequest(): void {
ipcMain.handle(
IPC_CHANNELS.GITLAB_UPDATE_MERGE_REQUEST,
async (
_event,
project: Project,
mrIid: number,
updates: Partial<CreateMergeRequestOptions>
): Promise<IPCResult<GitLabMergeRequest>> => {
debugLog('updateGitLabMergeRequest handler called', { mrIid });
const config = getGitLabConfig(project);
if (!config) {
return {
success: false,
error: 'GitLab not configured'
};
}
try {
const encodedProject = encodeProjectPath(config.project);
const mrBody: Record<string, unknown> = {};
if (updates.title) mrBody.title = updates.title;
if (updates.description) mrBody.description = updates.description;
if (updates.targetBranch) mrBody.target_branch = updates.targetBranch;
if (updates.labels) mrBody.labels = updates.labels.join(',');
if (updates.assigneeIds) mrBody.assignee_ids = updates.assigneeIds;
const apiMr = await gitlabFetch(
config.token,
config.instanceUrl,
`/projects/${encodedProject}/merge_requests/${mrIid}`,
{
method: 'PUT',
body: JSON.stringify(mrBody)
}
) as GitLabAPIMergeRequest;
debugLog('Merge request updated:', { iid: apiMr.iid });
const mr = transformMergeRequest(apiMr);
return {
success: true,
data: mr
};
} catch (error) {
debugLog('Failed to update merge request:', error instanceof Error ? error.message : error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to update merge request'
};
}
}
);
}
/**
* Register all merge request handlers
*/
export function registerMergeRequestHandlers(): void {
debugLog('Registering GitLab merge request handlers');
registerGetMergeRequests();
registerGetMergeRequest();
registerCreateMergeRequest();
registerUpdateMergeRequest();
debugLog('GitLab merge request handlers registered');
}
@@ -0,0 +1,752 @@
/**
* GitLab OAuth handlers using GitLab CLI (glab)
* Provides OAuth flow similar to GitHub's gh CLI
*/
import { ipcMain, shell } from 'electron';
import { execSync, execFileSync, spawn } from 'child_process';
import { IPC_CHANNELS } from '../../../shared/constants';
import type { IPCResult } from '../../../shared/types';
import { getAugmentedEnv, findExecutable } from '../../env-utils';
import type { GitLabAuthStartResult } from './types';
const DEFAULT_GITLAB_URL = 'https://gitlab.com';
// Debug logging helper
const DEBUG = process.env.DEBUG === 'true' || process.env.NODE_ENV === 'development';
/**
* Logs a message to the console with a "[GitLab OAuth]" prefix when debug mode is enabled.
*
* @param message - The message to log
* @param data - Optional additional data to include with the log
*/
function debugLog(message: string, data?: unknown): void {
if (DEBUG) {
if (data !== undefined) {
console.debug(`[GitLab OAuth] ${message}`, data);
} else {
console.debug(`[GitLab OAuth] ${message}`);
}
}
}
// Regex pattern to validate GitLab project format (group/project or group/subgroup/project)
const GITLAB_PROJECT_PATTERN = /^[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)+$/;
/**
* Determines whether a string is a valid GitLab project identifier.
*
* @param project - A GitLab project identifier: either a numeric project ID or a namespaced path (e.g., "group/project" or "group/subgroup/project").
* @returns `true` if `project` is a numeric ID or matches the expected namespaced path pattern, `false` otherwise.
*/
function isValidGitLabProject(project: string): boolean {
// Allow numeric IDs
if (/^\d+$/.test(project)) return true;
return GITLAB_PROJECT_PATTERN.test(project);
}
/**
* Return the hostname portion of a URL string.
*
* @returns The hostname extracted from `instanceUrl`, or `'gitlab.com'` if `instanceUrl` is not a valid URL.
*/
function getHostnameFromUrl(instanceUrl: string): string {
try {
return new URL(instanceUrl).hostname;
} catch {
return 'gitlab.com';
}
}
/**
* Register an IPC handler that checks whether the `glab` CLI is installed and, if present, returns its version.
*
* The handler listens on `IPC_CHANNELS.GITLAB_CHECK_CLI` and responds with an `IPCResult` whose `data` is
* `{ installed: boolean; version?: string }`. `installed` is `true` when `glab` is found; `version`, when present,
* contains the first line of the `glab --version` output.
*/
export function registerCheckGlabCli(): void {
ipcMain.handle(
IPC_CHANNELS.GITLAB_CHECK_CLI,
async (): Promise<IPCResult<{ installed: boolean; version?: string }>> => {
debugLog('checkGitLabCli handler called');
try {
const glabPath = findExecutable('glab');
if (!glabPath) {
debugLog('glab CLI not found in PATH or common locations');
return {
success: true,
data: { installed: false }
};
}
debugLog('glab CLI found at:', glabPath);
const versionOutput = execSync('glab --version', {
encoding: 'utf-8',
stdio: 'pipe',
env: getAugmentedEnv()
});
const version = versionOutput.trim().split('\n')[0];
debugLog('glab version:', version);
return {
success: true,
data: { installed: true, version }
};
} catch (error) {
debugLog('glab CLI not found or error:', error instanceof Error ? error.message : error);
return {
success: true,
data: { installed: false }
};
}
}
);
}
/**
* Register an IPC handler that checks whether the user is authenticated with the glab CLI for an optional GitLab instance.
*
* The handler listens on IPC_CHANNELS.GITLAB_CHECK_AUTH. When invoked it runs `glab auth status` (scoped to the provided instance URL when given) to determine authentication state. If authenticated, it attempts to fetch the current username via `glab api user`; if that succeeds the username is included in the handler result.
*/
export function registerCheckGlabAuth(): void {
ipcMain.handle(
IPC_CHANNELS.GITLAB_CHECK_AUTH,
async (_event, instanceUrl?: string): Promise<IPCResult<{ authenticated: boolean; username?: string }>> => {
debugLog('checkGitLabAuth handler called', { instanceUrl });
const env = getAugmentedEnv();
const hostname = instanceUrl ? getHostnameFromUrl(instanceUrl) : 'gitlab.com';
try {
// Check auth status for the specific host
const args = ['auth', 'status'];
if (hostname !== 'gitlab.com') {
args.push('--hostname', hostname);
}
debugLog('Running: glab', args);
execSync(`glab ${args.join(' ')}`, { encoding: 'utf-8', stdio: 'pipe', env });
// Get username if authenticated
try {
const userArgs = ['api', 'user', '--jq', '.username'];
if (hostname !== 'gitlab.com') {
userArgs.push('--hostname', hostname);
}
const username = execSync(`glab ${userArgs.join(' ')}`, {
encoding: 'utf-8',
stdio: 'pipe',
env
}).trim();
debugLog('Username:', username);
return {
success: true,
data: { authenticated: true, username }
};
} catch {
return {
success: true,
data: { authenticated: true }
};
}
} catch (error) {
debugLog('Auth check failed:', error instanceof Error ? error.message : error);
return {
success: true,
data: { authenticated: false }
};
}
}
);
}
/**
* Start GitLab OAuth flow using glab CLI
*/
export function registerStartGlabAuth(): void {
ipcMain.handle(
IPC_CHANNELS.GITLAB_START_AUTH,
async (_event, instanceUrl?: string): Promise<IPCResult<GitLabAuthStartResult>> => {
debugLog('startGitLabAuth handler called', { instanceUrl });
const hostname = instanceUrl ? getHostnameFromUrl(instanceUrl) : 'gitlab.com';
const deviceUrl = instanceUrl
? `${instanceUrl.replace(/\/$/, '')}/-/profile/personal_access_tokens`
: 'https://gitlab.com/-/profile/personal_access_tokens';
return new Promise((resolve) => {
try {
// glab auth login with web flow
const args = ['auth', 'login', '--web'];
if (hostname !== 'gitlab.com') {
args.push('--hostname', hostname);
}
debugLog('Spawning: glab', args);
const glabProcess = spawn('glab', args, {
stdio: ['pipe', 'pipe', 'pipe'],
env: getAugmentedEnv()
});
let output = '';
let errorOutput = '';
let browserOpened = false;
glabProcess.stdout?.on('data', (data) => {
const chunk = data.toString();
output += chunk;
debugLog('glab stdout:', chunk);
// Try to open browser if URL detected
const urlMatch = chunk.match(/https?:\/\/[^\s]+/);
if (urlMatch && !browserOpened) {
browserOpened = true;
shell.openExternal(urlMatch[0]).catch((err) => {
debugLog('Failed to open browser:', err);
});
}
});
glabProcess.stderr?.on('data', (data) => {
const chunk = data.toString();
errorOutput += chunk;
debugLog('glab stderr:', chunk);
});
glabProcess.on('close', (code) => {
debugLog('glab process exited with code:', code);
if (code === 0) {
resolve({
success: true,
data: {
deviceCode: '',
verificationUrl: deviceUrl,
userCode: ''
}
});
} else {
resolve({
success: false,
error: errorOutput || `Authentication failed with exit code ${code}`,
data: {
deviceCode: '',
verificationUrl: deviceUrl,
userCode: ''
}
});
}
});
glabProcess.on('error', (error) => {
debugLog('glab process error:', error.message);
resolve({
success: false,
error: error.message,
data: {
deviceCode: '',
verificationUrl: deviceUrl,
userCode: ''
}
});
});
} catch (error) {
debugLog('Exception in startGitLabAuth:', error instanceof Error ? error.message : error);
resolve({
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
data: {
deviceCode: '',
verificationUrl: deviceUrl,
userCode: ''
}
});
}
});
}
);
}
/**
* Registers an IPC handler that retrieves the current GitLab authentication token using the glab CLI for an optional instance URL.
*
* The registered handler listens on IPC_CHANNELS.GITLAB_GET_TOKEN and returns an IPCResult containing the token on success or an error message on failure.
*/
export function registerGetGlabToken(): void {
ipcMain.handle(
IPC_CHANNELS.GITLAB_GET_TOKEN,
async (_event, instanceUrl?: string): Promise<IPCResult<{ token: string }>> => {
debugLog('getGitLabToken handler called', { instanceUrl });
const hostname = instanceUrl ? getHostnameFromUrl(instanceUrl) : 'gitlab.com';
try {
const args = ['auth', 'token'];
if (hostname !== 'gitlab.com') {
args.push('--hostname', hostname);
}
const token = execSync(`glab ${args.join(' ')}`, {
encoding: 'utf-8',
stdio: 'pipe',
env: getAugmentedEnv()
}).trim();
if (!token) {
return {
success: false,
error: 'No token found. Please authenticate first.'
};
}
return {
success: true,
data: { token }
};
} catch (error) {
debugLog('Failed to get token:', error instanceof Error ? error.message : error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to get token'
};
}
}
);
}
/**
* Register an IPC handler that returns the authenticated GitLab user's username and name.
*
* The registered handler listens on IPC_CHANNELS.GITLAB_GET_USER and invokes the `glab api user`
* command to obtain the currently authenticated user's information. The handler accepts an
* optional `instanceUrl` argument to target a self-hosted GitLab instance; when provided the
* hostname is derived and passed to `glab`.
*
* On success the handler responds with an IPCResult whose `data` contains `username` and an
* optional `name`. On failure it responds with `success: false` and an `error` message.
*/
export function registerGetGlabUser(): void {
ipcMain.handle(
IPC_CHANNELS.GITLAB_GET_USER,
async (_event, instanceUrl?: string): Promise<IPCResult<{ username: string; name?: string }>> => {
debugLog('getGitLabUser handler called', { instanceUrl });
const hostname = instanceUrl ? getHostnameFromUrl(instanceUrl) : 'gitlab.com';
try {
const args = ['api', 'user'];
if (hostname !== 'gitlab.com') {
args.push('--hostname', hostname);
}
const userJson = execSync(`glab ${args.join(' ')}`, {
encoding: 'utf-8',
stdio: 'pipe',
env: getAugmentedEnv()
});
const user = JSON.parse(userJson);
debugLog('Parsed user:', { username: user.username, name: user.name });
return {
success: true,
data: {
username: user.username,
name: user.name
}
};
} catch (error) {
debugLog('Failed to get user info:', error instanceof Error ? error.message : error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to get user info'
};
}
}
);
}
/**
* Register an IPC handler that lists GitLab projects accessible to the authenticated user.
*
* The handler is registered on the GITLAB_LIST_USER_PROJECTS channel and accepts an optional
* `instanceUrl` to target a specific GitLab instance. When invoked it returns an IPCResult
* containing `projects`, each with `pathWithNamespace`, `description`, and `visibility`.
*/
export function registerListUserProjects(): void {
ipcMain.handle(
IPC_CHANNELS.GITLAB_LIST_USER_PROJECTS,
async (_event, instanceUrl?: string): Promise<IPCResult<{ projects: Array<{ pathWithNamespace: string; description: string | null; visibility: string }> }>> => {
debugLog('listUserProjects handler called', { instanceUrl });
const hostname = instanceUrl ? getHostnameFromUrl(instanceUrl) : 'gitlab.com';
try {
const args = ['repo', 'list', '--mine', '-F', 'json'];
if (hostname !== 'gitlab.com') {
args.push('--hostname', hostname);
}
const output = execSync(`glab ${args.join(' ')}`, {
encoding: 'utf-8',
stdio: 'pipe',
env: getAugmentedEnv()
});
const projects = JSON.parse(output);
debugLog('Found projects:', projects.length);
const formattedProjects = projects.map((p: { path_with_namespace: string; description: string | null; visibility: string }) => ({
pathWithNamespace: p.path_with_namespace,
description: p.description,
visibility: p.visibility
}));
return {
success: true,
data: { projects: formattedProjects }
};
} catch (error) {
debugLog('Failed to list projects:', error instanceof Error ? error.message : error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to list projects'
};
}
}
);
}
/**
* Register an IPC handler that detects a GitLab project and instance URL from a local repository's remote origin.
*
* The handler listens on IPC_CHANNELS.GITLAB_DETECT_PROJECT and expects a single argument `projectPath` (path to a local git repository). It returns an IPCResult containing `data` with `{ project, instanceUrl }` when a project path (e.g., "group/subgroup/project" or "group/project") and its GitLab instance URL are successfully parsed from the repository's `origin` remote; otherwise it returns an IPCResult with `success: false` and an `error` message.
*/
export function registerDetectGitLabProject(): void {
ipcMain.handle(
IPC_CHANNELS.GITLAB_DETECT_PROJECT,
async (_event, projectPath: string): Promise<IPCResult<{ project: string; instanceUrl: string }>> => {
debugLog('detectGitLabProject handler called', { projectPath });
try {
const remoteUrl = execSync('git remote get-url origin', {
encoding: 'utf-8',
cwd: projectPath,
stdio: 'pipe',
env: getAugmentedEnv()
}).trim();
debugLog('Remote URL:', remoteUrl);
// Parse GitLab project from URL
// SSH: git@gitlab.example.com:group/project.git
// HTTPS: https://gitlab.example.com/group/project.git
let instanceUrl = DEFAULT_GITLAB_URL;
let project = '';
const sshMatch = remoteUrl.match(/^git@([^:]+):(.+?)(?:\.git)?$/);
if (sshMatch) {
instanceUrl = `https://${sshMatch[1]}`;
project = sshMatch[2];
}
const httpsMatch = remoteUrl.match(/^https?:\/\/([^/]+)\/(.+?)(?:\.git)?$/);
if (httpsMatch) {
instanceUrl = `https://${httpsMatch[1]}`;
project = httpsMatch[2];
}
if (project) {
debugLog('Detected project:', { project, instanceUrl });
return {
success: true,
data: { project, instanceUrl }
};
}
return {
success: false,
error: 'Could not parse GitLab project from remote URL'
};
} catch (error) {
debugLog('Failed to detect project:', error instanceof Error ? error.message : error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to detect GitLab project'
};
}
}
);
}
/**
* Register an IPC handler that retrieves the branch names for a GitLab project using the glab CLI.
*
* The registered handler listens on IPC_CHANNELS.GITLAB_GET_BRANCHES, validates the project identifier,
* queries the project's repository branches, and returns an array of branch names or an error result.
*/
export function registerGetGitLabBranches(): void {
ipcMain.handle(
IPC_CHANNELS.GITLAB_GET_BRANCHES,
async (_event, project: string, instanceUrl: string): Promise<IPCResult<string[]>> => {
debugLog('getGitLabBranches handler called', { project, instanceUrl });
if (!isValidGitLabProject(project)) {
return {
success: false,
error: 'Invalid project format'
};
}
const hostname = getHostnameFromUrl(instanceUrl);
const encodedProject = encodeURIComponent(project);
try {
const args = ['api', `projects/${encodedProject}/repository/branches`, '--paginate', '--jq', '.[].name'];
if (hostname !== 'gitlab.com') {
args.push('--hostname', hostname);
}
const output = execFileSync('glab', args, {
encoding: 'utf-8',
stdio: 'pipe',
env: getAugmentedEnv()
});
const branches = output.trim().split('\n').filter(b => b.length > 0);
debugLog('Found branches:', branches.length);
return {
success: true,
data: branches
};
} catch (error) {
debugLog('Failed to get branches:', error instanceof Error ? error.message : error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to get branches'
};
}
}
);
}
/**
* Register an IPC handler that creates a new GitLab repository using the glab CLI.
*
* The registered handler validates the project name and accepts options for
* source path, description, visibility, namespace (group), and an optional
* GitLab instance URL. On success the handler returns an IPCResult with
* `data` containing `pathWithNamespace` and `webUrl`. On failure it returns
* an IPCResult with `success: false` and an `error` message.
*/
export function registerCreateGitLabProject(): void {
ipcMain.handle(
IPC_CHANNELS.GITLAB_CREATE_PROJECT,
async (
_event,
projectName: string,
options: { description?: string; visibility?: string; projectPath: string; namespace?: string; instanceUrl?: string }
): Promise<IPCResult<{ pathWithNamespace: string; webUrl: string }>> => {
debugLog('createGitLabProject handler called', { projectName, options });
if (!/^[A-Za-z0-9_.-]+$/.test(projectName)) {
return {
success: false,
error: 'Invalid project name'
};
}
const hostname = options.instanceUrl ? getHostnameFromUrl(options.instanceUrl) : 'gitlab.com';
try {
const args = ['repo', 'create', projectName, '--source', options.projectPath];
if (options.visibility) {
args.push('--visibility', options.visibility);
} else {
args.push('--visibility', 'private');
}
if (options.description) {
args.push('--description', options.description);
}
if (options.namespace) {
args.push('--group', options.namespace);
}
if (hostname !== 'gitlab.com') {
args.push('--hostname', hostname);
}
debugLog('Running: glab', args);
const output = execFileSync('glab', args, {
encoding: 'utf-8',
cwd: options.projectPath,
stdio: 'pipe',
env: getAugmentedEnv()
});
debugLog('glab repo create output:', output);
// Parse output to get project info
const urlMatch = output.match(/https?:\/\/[^\s]+/);
const webUrl = urlMatch ? urlMatch[0] : `https://${hostname}/${options.namespace || ''}/${projectName}`;
const pathWithNamespace = options.namespace ? `${options.namespace}/${projectName}` : projectName;
return {
success: true,
data: { pathWithNamespace, webUrl }
};
} catch (error) {
debugLog('Failed to create project:', error instanceof Error ? error.message : error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to create project'
};
}
}
);
}
/**
* Registers an IPC handler that adds or replaces the `origin` remote of a local git repository to point to a GitLab project.
*
* The handler validates the provided project path, constructs the GitLab remote URL using the optional instance URL (defaults to https://gitlab.com), removes any existing `origin` remote if present, and adds the new `origin`. The handler responds on the `GITLAB_ADD_REMOTE` IPC channel with an `IPCResult` containing the created `remoteUrl` on success or an error message on failure.
*/
export function registerAddGitLabRemote(): void {
ipcMain.handle(
IPC_CHANNELS.GITLAB_ADD_REMOTE,
async (
_event,
projectPath: string,
projectFullPath: string,
instanceUrl?: string
): Promise<IPCResult<{ remoteUrl: string }>> => {
debugLog('addGitLabRemote handler called', { projectPath, projectFullPath, instanceUrl });
if (!isValidGitLabProject(projectFullPath)) {
return {
success: false,
error: 'Invalid project format'
};
}
const baseUrl = (instanceUrl || DEFAULT_GITLAB_URL).replace(/\/$/, '');
const remoteUrl = `${baseUrl}/${projectFullPath}.git`;
try {
// Check if origin exists
try {
execSync('git remote get-url origin', {
cwd: projectPath,
encoding: 'utf-8',
stdio: 'pipe'
});
// Remove existing origin
execSync('git remote remove origin', {
cwd: projectPath,
encoding: 'utf-8',
stdio: 'pipe'
});
} catch {
// No origin exists
}
execFileSync('git', ['remote', 'add', 'origin', remoteUrl], {
cwd: projectPath,
encoding: 'utf-8',
stdio: 'pipe'
});
return {
success: true,
data: { remoteUrl }
};
} catch (error) {
debugLog('Failed to add remote:', error instanceof Error ? error.message : error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to add remote'
};
}
}
);
}
/**
* Register an IPC handler that lists GitLab groups accessible to the authenticated user.
*
* The handler listens on the GITLAB_LIST_GROUPS channel and calls the `glab api groups` command
* (optionally scoped to a provided instance hostname). It parses each line of `glab` output as
* JSON and returns an array of groups with `id`, `name`, `path`, and `fullPath`. On failure the
* handler responds successfully with an empty `groups` array.
*/
export function registerListGitLabGroups(): void {
ipcMain.handle(
IPC_CHANNELS.GITLAB_LIST_GROUPS,
async (_event, instanceUrl?: string): Promise<IPCResult<{ groups: Array<{ id: number; name: string; path: string; fullPath: string }> }>> => {
debugLog('listGitLabGroups handler called', { instanceUrl });
const hostname = instanceUrl ? getHostnameFromUrl(instanceUrl) : 'gitlab.com';
try {
const args = ['api', 'groups', '--jq', '.[] | {id: .id, name: .name, path: .path, fullPath: .full_path}'];
if (hostname !== 'gitlab.com') {
args.push('--hostname', hostname);
}
const output = execSync(`glab ${args.join(' ')}`, {
encoding: 'utf-8',
stdio: 'pipe',
env: getAugmentedEnv()
});
const groups: Array<{ id: number; name: string; path: string; fullPath: string }> = [];
const lines = output.trim().split('\n').filter(line => line.trim());
for (const line of lines) {
try {
const group = JSON.parse(line);
groups.push({
id: group.id,
name: group.name,
path: group.path,
fullPath: group.fullPath
});
} catch {
// Skip invalid JSON
}
}
return {
success: true,
data: { groups }
};
} catch (error) {
debugLog('Failed to list groups:', error instanceof Error ? error.message : error);
return {
success: true,
data: { groups: [] }
};
}
}
);
}
/**
* Register all GitLab OAuth handlers
*/
export function registerGitlabOAuthHandlers(): void {
debugLog('Registering GitLab OAuth handlers');
registerCheckGlabCli();
registerCheckGlabAuth();
registerStartGlabAuth();
registerGetGlabToken();
registerGetGlabUser();
registerListUserProjects();
registerDetectGitLabProject();
registerGetGitLabBranches();
registerCreateGitLabProject();
registerAddGitLabRemote();
registerListGitLabGroups();
debugLog('GitLab OAuth handlers registered');
}
@@ -0,0 +1,110 @@
/**
* GitLab release handlers
* Handles creating releases
*/
import { ipcMain } from 'electron';
import { IPC_CHANNELS } from '../../../shared/constants';
import type { IPCResult, Project } from '../../../shared/types';
import { getGitLabConfig, gitlabFetch, encodeProjectPath } from './utils';
import type { GitLabReleaseOptions } from './types';
// Debug logging helper
const DEBUG = process.env.DEBUG === 'true' || process.env.NODE_ENV === 'development';
/**
* Logs a prefixed debug message for GitLab release operations when debug mode is enabled.
*
* If additional `data` is provided it will be logged alongside the message.
*
* @param message - The message to log
* @param data - Optional additional data to include with the log
*/
function debugLog(message: string, data?: unknown): void {
if (DEBUG) {
if (data !== undefined) {
console.debug(`[GitLab Release] ${message}`, data);
} else {
console.debug(`[GitLab Release] ${message}`);
}
}
}
/**
* Registers an IPC handler that creates a release in GitLab.
*
* The registered handler listens on IPC_CHANNELS.GITLAB_CREATE_RELEASE and, when invoked,
* creates a release for the specified project and tag. The handler returns an IPCResult
* containing the created release URL on success or an error message on failure.
*/
export function registerCreateRelease(): void {
ipcMain.handle(
IPC_CHANNELS.GITLAB_CREATE_RELEASE,
async (
_event,
project: Project,
tagName: string,
releaseNotes: string,
options?: GitLabReleaseOptions
): Promise<IPCResult<{ url: string }>> => {
debugLog('createGitLabRelease handler called', { tagName });
const config = getGitLabConfig(project);
if (!config) {
return {
success: false,
error: 'GitLab not configured'
};
}
try {
const encodedProject = encodeProjectPath(config.project);
// Create the release
const releaseBody: Record<string, unknown> = {
tag_name: tagName,
description: options?.description || releaseNotes,
ref: options?.ref || 'main'
};
if (options?.milestones) {
releaseBody.milestones = options.milestones;
}
const release = await gitlabFetch(
config.token,
config.instanceUrl,
`/projects/${encodedProject}/releases`,
{
method: 'POST',
body: JSON.stringify(releaseBody)
}
) as { _links: { self: string } };
debugLog('Release created:', { tagName, url: release._links.self });
return {
success: true,
data: { url: release._links.self }
};
} catch (error) {
debugLog('Failed to create release:', error instanceof Error ? error.message : error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to create release'
};
}
}
);
}
/**
* Registers IPC handlers related to GitLab releases.
*
* Sets up all release-related handlers used by the Electron main process.
*/
export function registerReleaseHandlers(): void {
debugLog('Registering GitLab release handlers');
registerCreateRelease();
debugLog('GitLab release handlers registered');
}
@@ -0,0 +1,158 @@
/**
* GitLab repository handlers
* Handles connection status and project management
*/
import { ipcMain } from 'electron';
import { IPC_CHANNELS } from '../../../shared/constants';
import type { IPCResult, Project, GitLabSyncStatus } from '../../../shared/types';
import { getGitLabConfig, gitlabFetch, encodeProjectPath } from './utils';
import type { GitLabAPIProject, GitLabAPIIssue } from './types';
// Debug logging helper
const DEBUG = process.env.DEBUG === 'true' || process.env.NODE_ENV === 'development';
/**
* Logs debug messages for GitLab repository handlers when debug mode is enabled.
*
* @param message - The message to record
* @param data - Optional additional context to include with the message
*/
function debugLog(message: string, data?: unknown): void {
if (DEBUG) {
if (data !== undefined) {
console.debug(`[GitLab Repo] ${message}`, data);
} else {
console.debug(`[GitLab Repo] ${message}`);
}
}
}
/**
* Registers an IPC handler for GITLAB_CHECK_CONNECTION that checks a project's GitLab configuration and connectivity.
*
* The handler returns an IPCResult containing connection status and, when connected, the instance URL, project path with namespace, project description, open issue count, and a lastSyncedAt timestamp.
*/
export function registerCheckConnection(): void {
ipcMain.handle(
IPC_CHANNELS.GITLAB_CHECK_CONNECTION,
async (_event, project: Project): Promise<IPCResult<GitLabSyncStatus>> => {
debugLog('checkGitLabConnection handler called', { projectId: project.id });
const config = getGitLabConfig(project);
if (!config) {
debugLog('No GitLab config found');
return {
success: true,
data: {
connected: false,
error: 'GitLab not configured. Please add GITLAB_TOKEN and GITLAB_PROJECT to your .env file.'
}
};
}
try {
const encodedProject = encodeProjectPath(config.project);
// Fetch project info
const projectInfo = await gitlabFetch(
config.token,
config.instanceUrl,
`/projects/${encodedProject}`
) as GitLabAPIProject;
debugLog('Project info retrieved:', { name: projectInfo.name });
// Get issue count
const issues = await gitlabFetch(
config.token,
config.instanceUrl,
`/projects/${encodedProject}/issues?state=opened&per_page=1`
) as GitLabAPIIssue[];
// GitLab returns total count in headers, but for simplicity we just count opened
const issueCount = Array.isArray(issues) ? issues.length : 0;
return {
success: true,
data: {
connected: true,
instanceUrl: config.instanceUrl,
projectPathWithNamespace: projectInfo.path_with_namespace,
projectDescription: projectInfo.description,
issueCount,
lastSyncedAt: new Date().toISOString()
}
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to connect to GitLab';
debugLog('Connection check failed:', errorMessage);
return {
success: true,
data: {
connected: false,
error: errorMessage
}
};
}
}
);
}
/**
* Register an IPC handler that retrieves GitLab projects the current user can access.
*
* The handler listens on IPC_CHANNELS.GITLAB_GET_PROJECTS and expects a `Project` argument.
* If GitLab is not configured for the given project the handler returns a failure result with an error message.
* On success the handler returns an IPC result containing an array of `GitLabAPIProject`.
*/
export function registerGetProjects(): void {
ipcMain.handle(
IPC_CHANNELS.GITLAB_GET_PROJECTS,
async (_event, project: Project): Promise<IPCResult<GitLabAPIProject[]>> => {
debugLog('getGitLabProjects handler called');
const config = getGitLabConfig(project);
if (!config) {
return {
success: false,
error: 'GitLab not configured'
};
}
try {
const projects = await gitlabFetch(
config.token,
config.instanceUrl,
'/projects?membership=true&per_page=100'
) as GitLabAPIProject[];
debugLog('Found projects:', projects.length);
return {
success: true,
data: projects
};
} catch (error) {
debugLog('Failed to get projects:', error instanceof Error ? error.message : error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to get projects'
};
}
}
);
}
/**
* Register IPC handlers for GitLab repository operations.
*
* Registers the handlers used to check GitLab connection status and to retrieve
* accessible GitLab projects.
*/
export function registerRepositoryHandlers(): void {
debugLog('Registering GitLab repository handlers');
registerCheckConnection();
registerGetProjects();
debugLog('GitLab repository handlers registered');
}
@@ -0,0 +1,180 @@
/**
* GitLab spec utilities
* Handles creating task specs from GitLab issues
*/
import { existsSync, mkdirSync, writeFileSync } from 'fs';
import path from 'path';
import type { Project } from '../../../shared/types';
import type { GitLabAPIIssue, GitLabConfig } from './types';
/**
* Simplified task info returned when creating a spec from a GitLab issue.
* This is not a full Task object - it's just the basic info needed for the UI.
*/
export interface GitLabTaskInfo {
id: string;
specId: string;
title: string;
description: string;
createdAt: Date;
updatedAt: Date;
}
// Debug logging helper
const DEBUG = process.env.DEBUG === 'true' || process.env.NODE_ENV === 'development';
/**
* Logs a message prefixed with "[GitLab Spec]" to the console when debug mode is enabled.
*
* @param message - The text message to log
* @param data - Optional additional value to include in the log output
*/
function debugLog(message: string, data?: unknown): void {
if (DEBUG) {
if (data !== undefined) {
console.debug(`[GitLab Spec] ${message}`, data);
} else {
console.debug(`[GitLab Spec] ${message}`);
}
}
}
/**
* Create a filesystem-friendly spec directory name from a GitLab issue IID and title.
*
* @param issueIid - The issue IID used as a zero-padded 3-digit prefix
* @param title - The issue title to normalize into a directory-safe slug
* @returns The directory name in the form `NNN-cleaned-title`, where `NNN` is the zero-padded 3-digit IID and `cleaned-title` is a normalized, filesystem-friendly version of the title
*/
function generateSpecDirName(issueIid: number, title: string): string {
// Clean title for directory name
const cleanTitle = title
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.substring(0, 50);
// Format: 001-issue-title (padded issue IID)
const paddedIid = String(issueIid).padStart(3, '0');
return `${paddedIid}-${cleanTitle}`;
}
/**
* Create a Markdown-formatted context for a GitLab issue to include in a spec.
*
* @param issue - The GitLab issue object to render into the context
* @param projectPath - The repository or project path to display in the context
* @returns A Markdown string containing issue metadata (ID, project, state, dates, labels, assignees, milestone), the issue description, and the web URL suitable for writing to a TASK.md file
*/
export function buildIssueContext(issue: GitLabAPIIssue, projectPath: string): string {
const lines: string[] = [];
lines.push(`# GitLab Issue #${issue.iid}: ${issue.title}`);
lines.push('');
lines.push(`**Project:** ${projectPath}`);
lines.push(`**State:** ${issue.state}`);
lines.push(`**Created:** ${new Date(issue.created_at).toLocaleDateString()}`);
if (issue.labels.length > 0) {
lines.push(`**Labels:** ${issue.labels.join(', ')}`);
}
if (issue.assignees.length > 0) {
lines.push(`**Assignees:** ${issue.assignees.map(a => a.username).join(', ')}`);
}
if (issue.milestone) {
lines.push(`**Milestone:** ${issue.milestone.title}`);
}
lines.push('');
lines.push('## Description');
lines.push('');
lines.push(issue.description || '_No description provided_');
lines.push('');
lines.push(`**Web URL:** ${issue.web_url}`);
return lines.join('\n');
}
/**
* Create a filesystem spec directory and metadata for a GitLab issue, or return info for an existing spec.
*
* @param project - Project metadata containing `path` and `autoBuildPath` where specs are stored
* @param issue - GitLab API issue object used to populate the spec contents and metadata
* @param config - GitLab integration configuration (e.g., `instanceUrl` and `project`) included in the spec metadata
* @returns A `GitLabTaskInfo` describing the created or existing spec, or `null` if spec creation failed
*/
export async function createSpecForIssue(
project: Project,
issue: GitLabAPIIssue,
config: GitLabConfig
): Promise<GitLabTaskInfo | null> {
try {
const specsDir = path.join(project.path, project.autoBuildPath, 'specs');
// Ensure specs directory exists
if (!existsSync(specsDir)) {
mkdirSync(specsDir, { recursive: true });
}
// Generate spec directory name
const specDirName = generateSpecDirName(issue.iid, issue.title);
const specDir = path.join(specsDir, specDirName);
// Check if spec already exists
if (existsSync(specDir)) {
debugLog('Spec already exists for issue:', { iid: issue.iid, specDir });
// Return existing task info
return {
id: specDirName,
specId: specDirName,
title: issue.title,
description: issue.description || '',
createdAt: new Date(issue.created_at),
updatedAt: new Date()
};
}
// Create spec directory
mkdirSync(specDir, { recursive: true });
// Create TASK.md with issue context
const taskContent = buildIssueContext(issue, config.project);
writeFileSync(path.join(specDir, 'TASK.md'), taskContent, 'utf-8');
// Create metadata.json
const metadata = {
source: 'gitlab',
gitlab: {
issueId: issue.id,
issueIid: issue.iid,
instanceUrl: config.instanceUrl,
project: config.project,
webUrl: issue.web_url,
state: issue.state,
labels: issue.labels,
createdAt: issue.created_at
},
createdAt: new Date().toISOString(),
status: 'pending'
};
writeFileSync(path.join(specDir, 'metadata.json'), JSON.stringify(metadata, null, 2), 'utf-8');
debugLog('Created spec for issue:', { iid: issue.iid, specDir });
// Return task info
return {
id: specDirName,
specId: specDirName,
title: issue.title,
description: issue.description || '',
createdAt: new Date(issue.created_at),
updatedAt: new Date()
};
} catch (error) {
debugLog('Failed to create spec for issue:', { iid: issue.iid, error });
return null;
}
}
@@ -0,0 +1,226 @@
/**
* GitLab utility functions
*/
import { existsSync, readFileSync } from 'fs';
import { execSync } from 'child_process';
import path from 'path';
import type { Project } from '../../../shared/types';
import { parseEnvFile } from '../utils';
import type { GitLabConfig } from './types';
import { getAugmentedEnv } from '../../env-utils';
const DEFAULT_GITLAB_URL = 'https://gitlab.com';
/**
* Retrieve a GitLab access token from the glab CLI.
*
* @param instanceUrl - Optional GitLab instance URL to target (used for self-hosted instances)
* @returns The GitLab access token if found, `null` otherwise.
*/
function getTokenFromGlabCli(instanceUrl?: string): string | null {
try {
// glab auth token outputs the token for the current authenticated host
const args = ['auth', 'token'];
if (instanceUrl && !instanceUrl.includes('gitlab.com')) {
// For self-hosted, specify the hostname
const hostname = new URL(instanceUrl).hostname;
args.push('--hostname', hostname);
}
const token = execSync(`glab ${args.join(' ')}`, {
encoding: 'utf-8',
stdio: 'pipe',
env: getAugmentedEnv()
}).trim();
return token || null;
} catch {
return null;
}
}
/**
* Load GitLab token, instance URL, and project reference from a project's .env file.
*
* If `GITLAB_TOKEN` is not present in the file, attempts to retrieve a token from the `glab` CLI for the resolved instance URL. Returns `null` if the `.env` file is missing, cannot be read/parsed, or if either the token or project reference is not available.
*
* @param project - Project whose `path` and `autoBuildPath` are used to locate the `.env` file
* @returns A GitLabConfig containing `token`, `instanceUrl`, and `project` if all are found; `null` otherwise.
*/
export function getGitLabConfig(project: Project): GitLabConfig | null {
if (!project.autoBuildPath) return null;
const envPath = path.join(project.path, project.autoBuildPath, '.env');
if (!existsSync(envPath)) return null;
try {
const content = readFileSync(envPath, 'utf-8');
const vars = parseEnvFile(content);
let token: string | undefined = vars['GITLAB_TOKEN'];
const projectRef = vars['GITLAB_PROJECT'];
const instanceUrl = vars['GITLAB_INSTANCE_URL'] || DEFAULT_GITLAB_URL;
// If no token in .env, try to get it from glab CLI
if (!token) {
const glabToken = getTokenFromGlabCli(instanceUrl);
if (glabToken) {
token = glabToken;
}
}
if (!token || !projectRef) return null;
return { token, instanceUrl, project: projectRef };
} catch {
return null;
}
}
/**
* Normalize a GitLab project reference into a namespace/path or return a numeric project ID unchanged.
*
* @param project - Project reference to normalize; may be a numeric ID, a namespace path (e.g., `group/project` or `group/subgroup/project`), an HTTPS URL, or an SSH URL.
* @param instanceUrl - GitLab base URL used to identify and strip host-specific prefixes (defaults to the module's default GitLab URL).
* @returns The normalized project path such as `group/project` or `group/subgroup/project`, the original numeric ID if `project` is numeric, or an empty string when `project` is empty.
*/
export function normalizeProjectReference(project: string, instanceUrl: string = DEFAULT_GITLAB_URL): string {
if (!project) return '';
// If it's a numeric ID, return as-is
if (/^\d+$/.test(project)) {
return project;
}
// Remove trailing .git if present
let normalized = project.replace(/\.git$/, '');
// Extract hostname for comparison
const gitlabHostname = new URL(instanceUrl).hostname;
// Handle full GitLab URLs
const httpsPattern = new RegExp(`https?://${gitlabHostname}/`);
if (httpsPattern.test(normalized)) {
normalized = normalized.replace(httpsPattern, '');
} else if (normalized.startsWith(`git@${gitlabHostname}:`)) {
normalized = normalized.replace(`git@${gitlabHostname}:`, '');
}
return normalized.trim();
}
/**
* Encode a GitLab project path for use in API URLs.
*
* Numeric project IDs are returned unchanged; otherwise the path is URL-encoded
* (for example, `group/project` becomes `group%2Fproject`).
*
* @param projectPath - Project path or numeric project ID
* @returns The encoded project path suitable for GitLab API requests
*/
export function encodeProjectPath(projectPath: string): string {
// If it's a numeric ID, return as-is
if (/^\d+$/.test(projectPath)) {
return projectPath;
}
return encodeURIComponent(projectPath);
}
/**
* Perform an authenticated request to the GitLab API and return the parsed JSON response.
*
* The `PRIVATE-TOKEN` header is set from `token`, and `Content-Type` defaults to `application/json`.
*
* @param token - Personal access token placed in the `PRIVATE-TOKEN` header
* @param instanceUrl - Base GitLab instance URL used when `endpoint` is a relative path
* @param endpoint - Full URL or a path appended to `{instanceUrl}/api/v4`; if it starts with `http` it is used as-is
* @param options - Additional fetch options; provided headers are merged with defaults
* @returns The response body parsed as JSON
* @throws Error if the HTTP response has a non-OK status; the error message includes status, statusText, and response body
*/
export async function gitlabFetch(
token: string,
instanceUrl: string,
endpoint: string,
options: RequestInit = {}
): Promise<unknown> {
// Ensure instanceUrl doesn't have trailing slash
const baseUrl = instanceUrl.replace(/\/$/, '');
const url = endpoint.startsWith('http')
? endpoint
: `${baseUrl}/api/v4${endpoint}`;
const response = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
'PRIVATE-TOKEN': token, // GitLab uses PRIVATE-TOKEN header
...options.headers
}
});
if (!response.ok) {
const errorBody = await response.text();
throw new Error(`GitLab API error: ${response.status} ${response.statusText} - ${errorBody}`);
}
return response.json();
}
/**
* Resolve a GitLab project's numeric ID from a project path or numeric ID.
*
* @param pathWithNamespace - The project's path with namespace (e.g., `group/subgroup/project`) or a numeric project ID
* @returns The project's numeric ID
*/
export async function getProjectIdFromPath(
token: string,
instanceUrl: string,
pathWithNamespace: string
): Promise<number> {
const encodedPath = encodeProjectPath(pathWithNamespace);
const project = await gitlabFetch(token, instanceUrl, `/projects/${encodedPath}`) as { id: number };
return project.id;
}
/**
* Infers the GitLab project path and instance URL from the repository's `origin` remote.
*
* @param projectPath - Filesystem path of the git repository to inspect
* @returns An object with `project` as the namespace/path (for example, `group/project`) and `instanceUrl` as the GitLab base URL (for example, `https://gitlab.com`), or `null` if the remote cannot be parsed or detection fails
*/
export function detectGitLabProjectFromRemote(projectPath: string): { project: string; instanceUrl: string } | null {
try {
const remoteUrl = execSync('git remote get-url origin', {
cwd: projectPath,
encoding: 'utf-8',
stdio: 'pipe',
env: getAugmentedEnv()
}).trim();
if (!remoteUrl) return null;
// Parse the remote URL to extract instance URL and project path
let instanceUrl = DEFAULT_GITLAB_URL;
let project = '';
// SSH format: git@gitlab.example.com:group/project.git
const sshMatch = remoteUrl.match(/^git@([^:]+):(.+?)(?:\.git)?$/);
if (sshMatch) {
instanceUrl = `https://${sshMatch[1]}`;
project = sshMatch[2];
}
// HTTPS format: https://gitlab.example.com/group/project.git
const httpsMatch = remoteUrl.match(/^https?:\/\/([^/]+)\/(.+?)(?:\.git)?$/);
if (httpsMatch) {
instanceUrl = `https://${httpsMatch[1]}`;
project = httpsMatch[2];
}
if (project) {
return { project, instanceUrl };
}
return null;
} catch {
return null;
}
}
+11 -6
View File
@@ -22,6 +22,7 @@ import { registerContextHandlers } from './context-handlers';
import { registerEnvHandlers } from './env-handlers';
import { registerLinearHandlers } from './linear-handlers';
import { registerGithubHandlers } from './github-handlers';
import { registerGitlabHandlers } from './gitlab-handlers';
import { registerAutobuildSourceHandlers } from './autobuild-source-handlers';
import { registerIdeationHandlers } from './ideation-handlers';
import { registerChangelogHandlers } from './changelog-handlers';
@@ -31,12 +32,12 @@ import { registerAppUpdateHandlers } from './app-update-handlers';
import { notificationService } from '../notification-service';
/**
* Setup all IPC handlers across all domains
* Register all IPC handlers for the application's domains.
*
* @param agentManager - The agent manager instance
* @param terminalManager - The terminal manager instance
* @param getMainWindow - Function to get the main BrowserWindow
* @param pythonEnvManager - The Python environment manager instance
* @param agentManager - Manager responsible for agent lifecycle and events
* @param terminalManager - Manager responsible for terminal sessions
* @param getMainWindow - Function that returns the main BrowserWindow or `null`
* @param pythonEnvManager - Manager for Python environment configuration and tooling
*/
export function setupIpcHandlers(
agentManager: AgentManager,
@@ -80,6 +81,9 @@ export function setupIpcHandlers(
// GitHub integration handlers
registerGithubHandlers(agentManager, getMainWindow);
// GitLab integration handlers
registerGitlabHandlers(agentManager, getMainWindow);
// Auto-build source update handlers
registerAutobuildSourceHandlers(getMainWindow);
@@ -114,10 +118,11 @@ export {
registerEnvHandlers,
registerLinearHandlers,
registerGithubHandlers,
registerGitlabHandlers,
registerAutobuildSourceHandlers,
registerIdeationHandlers,
registerChangelogHandlers,
registerInsightsHandlers,
registerMemoryHandlers,
registerAppUpdateHandlers
};
};
@@ -0,0 +1,156 @@
import { useState, useCallback, useMemo } from 'react';
import { useProjectStore } from '../stores/project-store';
import { useTaskStore } from '../stores/task-store';
import { useGitLabIssues, useGitLabInvestigation, useIssueFiltering } from './gitlab-issues/hooks';
import {
NotConnectedState,
EmptyState,
IssueListHeader,
IssueList,
IssueDetail,
InvestigationDialog
} from './gitlab-issues/components';
import type { GitLabIssue } from '../../shared/types';
import type { GitLabIssuesProps } from './gitlab-issues/types';
/**
* Render the GitLab issues interface for the currently selected project, including the header, filtered issue list, detail pane, and investigation dialog.
*
* Renders a NotConnectedState when GitLab sync is unavailable; otherwise shows IssueListHeader, IssueList, IssueDetail (or an EmptyState when no issue is selected), and InvestigationDialog.
*
* @param onOpenSettings - Callback invoked to open GitLab integration settings
* @param onNavigateToTask - Callback invoked with a task id to navigate to the corresponding task
* @returns The UI element that composes the issues header, list, detail view, and investigation dialog for the selected project
*/
export function GitLabIssues({ onOpenSettings, onNavigateToTask }: GitLabIssuesProps) {
const projects = useProjectStore((state) => state.projects);
const selectedProjectId = useProjectStore((state) => state.selectedProjectId);
const selectedProject = projects.find((p) => p.id === selectedProjectId);
const tasks = useTaskStore((state) => state.tasks);
const {
issues,
syncStatus,
isLoading,
error,
selectedIssueIid,
filterState,
selectIssue,
getFilteredIssues,
getOpenIssuesCount,
handleRefresh,
handleFilterChange
} = useGitLabIssues(selectedProject?.id);
const {
investigationStatus,
lastInvestigationResult,
startInvestigation,
resetInvestigationStatus
} = useGitLabInvestigation(selectedProject?.id);
const { searchQuery, setSearchQuery, filteredIssues } = useIssueFiltering(getFilteredIssues());
const [showInvestigateDialog, setShowInvestigateDialog] = useState(false);
const [selectedIssueForInvestigation, setSelectedIssueForInvestigation] = useState<GitLabIssue | null>(null);
// Build a map of GitLab issue IIDs to task IDs for quick lookup
const issueToTaskMap = useMemo(() => {
const map = new Map<number, string>();
for (const task of tasks) {
if (task.metadata?.gitlabIssueIid) {
map.set(task.metadata.gitlabIssueIid, task.specId || task.id);
}
}
return map;
}, [tasks]);
const handleInvestigate = useCallback((issue: GitLabIssue) => {
setSelectedIssueForInvestigation(issue);
setShowInvestigateDialog(true);
}, []);
const handleStartInvestigation = useCallback((selectedNoteIds: number[]) => {
if (selectedIssueForInvestigation) {
startInvestigation(selectedIssueForInvestigation, selectedNoteIds);
}
}, [selectedIssueForInvestigation, startInvestigation]);
const handleCloseDialog = useCallback(() => {
setShowInvestigateDialog(false);
resetInvestigationStatus();
}, [resetInvestigationStatus]);
const selectedIssue = issues.find(i => i.iid === selectedIssueIid);
// Not connected state
if (!syncStatus?.connected) {
return (
<NotConnectedState
error={syncStatus?.error || null}
onOpenSettings={onOpenSettings}
/>
);
}
return (
<div className="flex-1 flex flex-col h-full">
{/* Header */}
<IssueListHeader
projectPath={syncStatus.projectPathWithNamespace ?? ''}
openIssuesCount={getOpenIssuesCount()}
isLoading={isLoading}
searchQuery={searchQuery}
filterState={filterState}
onSearchChange={setSearchQuery}
onFilterChange={handleFilterChange}
onRefresh={handleRefresh}
/>
{/* Content */}
<div className="flex-1 flex min-h-0">
{/* Issue List */}
<div className="w-1/2 border-r border-border flex flex-col">
<IssueList
issues={filteredIssues}
selectedIssueIid={selectedIssueIid}
isLoading={isLoading}
error={error}
onSelectIssue={selectIssue}
onInvestigate={handleInvestigate}
/>
</div>
{/* Issue Detail */}
<div className="w-1/2 flex flex-col">
{selectedIssue ? (
<IssueDetail
issue={selectedIssue}
onInvestigate={() => handleInvestigate(selectedIssue)}
investigationResult={
lastInvestigationResult?.issueIid === selectedIssue.iid
? lastInvestigationResult
: null
}
linkedTaskId={issueToTaskMap.get(selectedIssue.iid)}
onViewTask={onNavigateToTask}
/>
) : (
<EmptyState message="Select an issue to view details" />
)}
</div>
</div>
{/* Investigation Dialog */}
<InvestigationDialog
open={showInvestigateDialog}
onOpenChange={setShowInvestigateDialog}
selectedIssue={selectedIssueForInvestigation}
investigationStatus={investigationStatus}
onStartInvestigation={handleStartInvestigation}
onClose={handleCloseDialog}
projectId={selectedProject?.id}
/>
</div>
);
}
@@ -0,0 +1,56 @@
import { GitlabIcon, Settings2 } from 'lucide-react';
import { Button } from '../../ui/button';
import type { EmptyStateProps, NotConnectedStateProps } from '../types';
/**
* Render an empty-state UI that displays a circular icon and a contextual message.
*
* When `searchQuery` is provided, the message "No issues match your search" is shown;
* otherwise the supplied `message` is displayed.
*
* @param searchQuery - Current search string that, when non-empty, overrides the displayed message
* @param icon - Icon component to display inside the circular badge (defaults to `GitlabIcon`)
* @param message - Message to show when `searchQuery` is empty
* @returns A JSX element representing the empty-state container with icon and message
*/
export function EmptyState({ searchQuery, icon: Icon = GitlabIcon, message }: EmptyStateProps) {
return (
<div className="flex-1 flex flex-col items-center justify-center p-8 text-center">
<div className="w-12 h-12 rounded-full bg-muted/50 flex items-center justify-center mb-3">
<Icon className="h-6 w-6 text-muted-foreground" />
</div>
<p className="text-sm text-muted-foreground">
{searchQuery ? 'No issues match your search' : message}
</p>
</div>
);
}
/**
* Displays a GitLab "not connected" UI with an optional error message and an optional "Open Settings" action.
*
* @param error - Optional error message to display; when omitted a default guidance message is shown.
* @param onOpenSettings - Optional callback invoked when the "Open Settings" button is clicked; when omitted the button is not rendered.
* @returns A React element rendering the NotConnectedState UI.
*/
export function NotConnectedState({ error, onOpenSettings }: NotConnectedStateProps) {
return (
<div className="flex-1 flex flex-col items-center justify-center p-8 text-center">
<div className="w-16 h-16 rounded-full bg-muted/50 flex items-center justify-center mb-4">
<GitlabIcon className="h-8 w-8 text-orange-500" />
</div>
<h3 className="text-lg font-semibold text-foreground mb-2">
GitLab Not Connected
</h3>
<p className="text-sm text-muted-foreground mb-4 max-w-md">
{error || 'Configure your GitLab token and project in project settings to sync issues.'}
</p>
{onOpenSettings && (
<Button onClick={onOpenSettings} variant="outline">
<Settings2 className="h-4 w-4 mr-2" />
Open Settings
</Button>
)}
</div>
);
}
@@ -0,0 +1,257 @@
import { useEffect, useState } from 'react';
import { Sparkles, Loader2, CheckCircle2, MessageCircle } from 'lucide-react';
import { Button } from '../../ui/button';
import { Progress } from '../../ui/progress';
import { Checkbox } from '../../ui/checkbox';
import { ScrollArea } from '../../ui/scroll-area';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '../../ui/dialog';
import type { InvestigationDialogProps } from '../types';
import { formatDate } from '../utils';
import type { GitLabNote } from '../../../../shared/types';
/**
* Renders a dialog UI to create a task from a GitLab issue by selecting which issue notes to include.
*
* The dialog fetches and lists non-system notes for the selected issue (selecting all by default),
* allows toggling individual or all notes, and starts an investigation workflow with the chosen notes.
*
* @param open - Whether the dialog is open
* @param onOpenChange - Callback invoked when the dialog open state should change
* @param selectedIssue - The GitLab issue to create a task from; its notes are fetched when the dialog opens
* @param investigationStatus - Current investigation workflow state (phase, message, progress, error) used to drive UI states
* @param onStartInvestigation - Called with an array of selected note IDs to begin creating the task
* @param onClose - Callback invoked when the dialog is closed after completion
* @param projectId - GitLab project identifier used to fetch issue notes
* @returns A React element rendering the InvestigationDialog UI
*/
export function InvestigationDialog({
open,
onOpenChange,
selectedIssue,
investigationStatus,
onStartInvestigation,
onClose,
projectId
}: InvestigationDialogProps) {
const [notes, setNotes] = useState<GitLabNote[]>([]);
const [selectedNoteIds, setSelectedNoteIds] = useState<number[]>([]);
const [loadingNotes, setLoadingNotes] = useState(false);
const [fetchNotesError, setFetchNotesError] = useState<string | null>(null);
// Fetch notes when dialog opens
useEffect(() => {
if (open && selectedIssue && projectId) {
let isMounted = true;
setLoadingNotes(true);
setNotes([]);
setSelectedNoteIds([]);
setFetchNotesError(null);
window.electronAPI.getGitLabIssueNotes(projectId, selectedIssue.iid)
.then((result: { success: boolean; data?: GitLabNote[] }) => {
if (!isMounted) return;
if (result.success && result.data) {
// Filter out system notes
const userNotes = result.data.filter(n => !n.system);
setNotes(userNotes);
// By default, select all notes
setSelectedNoteIds(userNotes.map((n: GitLabNote) => n.id));
}
})
.catch((err: unknown) => {
if (!isMounted) return;
console.error('Failed to fetch notes:', err);
setFetchNotesError(
err instanceof Error ? err.message : 'Failed to load notes'
);
})
.finally(() => {
if (isMounted) {
setLoadingNotes(false);
}
});
return () => {
isMounted = false;
};
}
}, [open, selectedIssue, projectId]);
const toggleNote = (noteId: number) => {
setSelectedNoteIds(prev =>
prev.includes(noteId)
? prev.filter(id => id !== noteId)
: [...prev, noteId]
);
};
const toggleAllNotes = () => {
if (selectedNoteIds.length === notes.length) {
setSelectedNoteIds([]);
} else {
setSelectedNoteIds(notes.map(n => n.id));
}
};
const handleStartInvestigation = () => {
onStartInvestigation(selectedNoteIds);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[80vh] flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-info" />
Create Task from Issue
</DialogTitle>
<DialogDescription>
{selectedIssue && (
<span>
Issue #{selectedIssue.iid}: {selectedIssue.title}
</span>
)}
</DialogDescription>
</DialogHeader>
{investigationStatus.phase === 'idle' ? (
<div className="space-y-4 flex-1 min-h-0 flex flex-col">
<p className="text-sm text-muted-foreground">
Create a task from this GitLab issue. The task will be added to your Kanban board in the Backlog column.
</p>
{/* Notes section */}
{loadingNotes ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : fetchNotesError ? (
<div className="rounded-lg bg-destructive/10 border border-destructive/30 p-4">
<p className="text-sm text-destructive font-medium">Failed to load notes</p>
<p className="text-xs text-destructive/80 mt-1">{fetchNotesError}</p>
</div>
) : notes.length > 0 ? (
<div className="space-y-2 flex-1 min-h-0 flex flex-col">
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium flex items-center gap-2">
<MessageCircle className="h-4 w-4" />
Select Notes to Include ({selectedNoteIds.length}/{notes.length})
</h4>
<Button
variant="ghost"
size="sm"
onClick={toggleAllNotes}
className="text-xs"
>
{selectedNoteIds.length === notes.length ? 'Deselect All' : 'Select All'}
</Button>
</div>
<ScrollArea className="flex-1 min-h-0 border rounded-md">
<div className="p-2 space-y-2">
{notes.map((note) => (
<div
key={note.id}
role="button"
tabIndex={0}
className="flex gap-3 p-3 rounded-lg border border-border bg-card hover:bg-accent/50 transition-colors cursor-pointer"
onClick={() => toggleNote(note.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggleNote(note.id);
}
}}
>
<Checkbox
checked={selectedNoteIds.includes(note.id)}
onCheckedChange={() => toggleNote(note.id)}
onClick={(e) => e.stopPropagation()}
/>
<div className="flex-1 space-y-1 min-w-0">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="font-medium">{note.author.username}</span>
<span></span>
<span>{formatDate(note.createdAt)}</span>
</div>
<p className="text-sm text-foreground whitespace-pre-wrap break-words line-clamp-3">
{note.body}
</p>
</div>
</div>
))}
</div>
</ScrollArea>
</div>
) : (
<div className="rounded-lg border border-border bg-muted/30 p-4">
<h4 className="text-sm font-medium mb-2">The task will include:</h4>
<ul className="text-sm text-muted-foreground space-y-1">
<li> Issue title and description</li>
<li> Link back to the GitLab issue</li>
<li> Labels and metadata from the issue</li>
<li> No notes (this issue has no notes)</li>
</ul>
</div>
)}
</div>
) : (
<div className="space-y-4">
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">{investigationStatus.message}</span>
<span className="text-foreground">{investigationStatus.progress}%</span>
</div>
<Progress value={investigationStatus.progress} className="h-2" />
</div>
{investigationStatus.phase === 'error' && (
<div className="rounded-lg bg-destructive/10 border border-destructive/30 p-3 text-sm text-destructive">
{investigationStatus.error}
</div>
)}
{investigationStatus.phase === 'complete' && (
<div className="rounded-lg bg-success/10 border border-success/30 p-3 flex items-center gap-2 text-sm text-success">
<CheckCircle2 className="h-4 w-4" />
Task created! View it in your Kanban board.
</div>
)}
</div>
)}
<DialogFooter>
{investigationStatus.phase === 'idle' && (
<>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleStartInvestigation}>
<Sparkles className="h-4 w-4 mr-2" />
Create Task
</Button>
</>
)}
{investigationStatus.phase !== 'idle' && investigationStatus.phase !== 'complete' && (
<Button variant="outline" disabled>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Creating...
</Button>
)}
{investigationStatus.phase === 'complete' && (
<Button onClick={onClose}>
Done
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,212 @@
import { ExternalLink, User, Clock, MessageCircle, Sparkles, CheckCircle2, Eye } from 'lucide-react';
import { Badge } from '../../ui/badge';
import { Button } from '../../ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '../../ui/card';
import { ScrollArea } from '../../ui/scroll-area';
import { formatDate } from '../utils';
import type { IssueDetailProps } from '../types';
// GitLab issue state colors and labels
const GITLAB_ISSUE_STATE_COLORS: Record<string, string> = {
opened: 'bg-green-500/10 text-green-500 border-green-500/20',
closed: 'bg-purple-500/10 text-purple-500 border-purple-500/20'
};
const GITLAB_ISSUE_STATE_LABELS: Record<string, string> = {
opened: 'Open',
closed: 'Closed'
};
const GITLAB_COMPLEXITY_COLORS: Record<string, string> = {
simple: 'bg-green-500/10 text-green-500',
standard: 'bg-yellow-500/10 text-yellow-500',
complex: 'bg-red-500/10 text-red-500'
};
/**
* Render a scrollable panel showing detailed information about a GitLab issue and task linkage actions.
*
* Renders issue state, IID, external link, title, author, creation date, notes count, labels, description,
* assignees, and milestone. Shows either a "Create Task" action or a "View Task" action when a task is linked.
* If a task is linked, displays task details and (when available) investigation analysis including summary
* and estimated complexity.
*
* @param issue - The GitLab issue object to display
* @param onInvestigate - Callback invoked when the user requests to create/investigate a task for this issue
* @param investigationResult - Optional result of an investigation attempt; used to show linked task details and analysis
* @param linkedTaskId - Optional externally-provided task ID that takes precedence over investigationResult.taskId
* @param onViewTask - Optional callback invoked with the task ID when the user requests to view the linked task
* @returns The rendered issue detail panel as a React element
*/
export function IssueDetail({ issue, onInvestigate, investigationResult, linkedTaskId, onViewTask }: IssueDetailProps) {
// Determine which task ID to use - either already linked or just created
const taskId = linkedTaskId || (investigationResult?.success ? investigationResult.taskId : undefined);
const hasLinkedTask = !!taskId;
const handleViewTask = () => {
if (taskId && onViewTask) {
onViewTask(taskId);
}
};
return (
<ScrollArea className="flex-1">
<div className="p-4 space-y-4">
{/* Header */}
<div className="space-y-2">
<div className="flex items-start justify-between gap-4">
<div className="flex items-center gap-2">
<Badge
variant="outline"
className={`${GITLAB_ISSUE_STATE_COLORS[issue.state] || ''}`}
>
{GITLAB_ISSUE_STATE_LABELS[issue.state] || issue.state}
</Badge>
<span className="text-sm text-muted-foreground">#{issue.iid}</span>
</div>
<Button variant="ghost" size="icon" asChild>
<a href={issue.webUrl} target="_blank" rel="noopener noreferrer">
<ExternalLink className="h-4 w-4" />
</a>
</Button>
</div>
<h2 className="text-lg font-semibold text-foreground">
{issue.title}
</h2>
</div>
{/* Meta */}
<div className="flex flex-wrap items-center gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-1">
<User className="h-4 w-4" />
{issue.author.username}
</div>
<div className="flex items-center gap-1">
<Clock className="h-4 w-4" />
{formatDate(issue.createdAt)}
</div>
{issue.userNotesCount > 0 && (
<div className="flex items-center gap-1">
<MessageCircle className="h-4 w-4" />
{issue.userNotesCount} notes
</div>
)}
</div>
{/* Labels */}
{issue.labels.length > 0 && (
<div className="flex flex-wrap gap-2">
{issue.labels.map((label, index) => (
<Badge
key={index}
variant="outline"
className="bg-orange-500/10 text-orange-500 border-orange-500/20"
>
{label}
</Badge>
))}
</div>
)}
{/* Actions */}
<div className="flex items-center gap-2">
{hasLinkedTask ? (
<Button onClick={handleViewTask} className="flex-1" variant="secondary">
<Eye className="h-4 w-4 mr-2" />
View Task
</Button>
) : (
<Button onClick={onInvestigate} className="flex-1">
<Sparkles className="h-4 w-4 mr-2" />
Create Task
</Button>
)}
</div>
{/* Task Linked Info */}
{hasLinkedTask && (
<Card className="bg-success/5 border-success/30">
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2 text-success">
<CheckCircle2 className="h-4 w-4" />
Task Linked
</CardTitle>
</CardHeader>
<CardContent className="text-sm space-y-2">
{investigationResult?.success ? (
<>
<p className="text-foreground">{investigationResult.analysis.summary}</p>
<div className="flex items-center gap-2">
<Badge className={GITLAB_COMPLEXITY_COLORS[investigationResult.analysis.estimatedComplexity]}>
{investigationResult.analysis.estimatedComplexity}
</Badge>
<span className="text-xs text-muted-foreground">
Task ID: {taskId}
</span>
</div>
</>
) : (
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
Task ID: {taskId}
</span>
</div>
)}
</CardContent>
</Card>
)}
{/* Body */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Description</CardTitle>
</CardHeader>
<CardContent>
{issue.description ? (
<div className="prose prose-sm prose-invert max-w-none">
<pre className="whitespace-pre-wrap text-sm text-muted-foreground font-sans">
{issue.description}
</pre>
</div>
) : (
<p className="text-sm text-muted-foreground italic">
No description provided.
</p>
)}
</CardContent>
</Card>
{/* Assignees */}
{issue.assignees.length > 0 && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Assignees</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{issue.assignees.map((assignee) => (
<Badge key={assignee.username} variant="outline">
<User className="h-3 w-3 mr-1" />
{assignee.username}
</Badge>
))}
</div>
</CardContent>
</Card>
)}
{/* Milestone */}
{issue.milestone && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Milestone</CardTitle>
</CardHeader>
<CardContent>
<Badge variant="outline">{issue.milestone.title}</Badge>
</CardContent>
</Card>
)}
</div>
</ScrollArea>
);
}
@@ -0,0 +1,66 @@
import { Loader2, AlertCircle } from 'lucide-react';
import { ScrollArea } from '../../ui/scroll-area';
import { IssueListItem } from './IssueListItem';
import { EmptyState } from './EmptyStates';
import type { IssueListProps } from '../types';
/**
* Render a list of issues with handling for error, loading, and empty states.
*
* Renders an error banner when `error` is set, a centered loading indicator when `isLoading` is true, an empty state when `issues` is empty, and otherwise a scrollable list of issue items.
*
* @param issues - Array of issue objects to display.
* @param selectedIssueIid - The IID of the currently selected issue; used to mark an item as selected.
* @param isLoading - When true, shows a loading indicator instead of the list.
* @param error - Optional error message to show in an error banner.
* @param onSelectIssue - Callback invoked with an issue IID when an item is selected.
* @param onInvestigate - Callback invoked with the full issue object when the investigate action is triggered.
* @returns The JSX element representing the issue list or an appropriate UI state (error, loading, or empty).
*/
export function IssueList({
issues,
selectedIssueIid,
isLoading,
error,
onSelectIssue,
onInvestigate
}: IssueListProps) {
if (error) {
return (
<div className="p-4 bg-destructive/10 border-b border-destructive/30">
<div className="flex items-center gap-2 text-sm text-destructive">
<AlertCircle className="h-4 w-4" />
{error}
</div>
</div>
);
}
if (isLoading) {
return (
<div className="flex-1 flex items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
if (issues.length === 0) {
return <EmptyState message="No issues found" />;
}
return (
<ScrollArea className="flex-1">
<div className="p-2 space-y-1">
{issues.map((issue) => (
<IssueListItem
key={issue.id}
issue={issue}
isSelected={selectedIssueIid === issue.iid}
onClick={() => onSelectIssue(issue.iid)}
onInvestigate={() => onInvestigate(issue)}
/>
))}
</div>
</ScrollArea>
);
}
@@ -0,0 +1,93 @@
import { GitlabIcon, RefreshCw, Search, Filter } from 'lucide-react';
import { Badge } from '../../ui/badge';
import { Button } from '../../ui/button';
import { Input } from '../../ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '../../ui/select';
import type { IssueListHeaderProps } from '../types';
/**
* Renders the header and filter controls for a GitLab issues list.
*
* @param projectPath - Repository path displayed below the title
* @param openIssuesCount - Number of open issues shown in the badge
* @param isLoading - When true, disables interactions and shows a loading state for the refresh control
* @param searchQuery - Current text value of the search input
* @param filterState - Current filter value; one of `"opened"`, `"closed"`, or `"all"`
* @param onSearchChange - Called when the search text changes with the new value
* @param onFilterChange - Called when the filter selection changes with the new value (`"opened" | "closed" | "all"`)
* @param onRefresh - Called when the refresh button is clicked
* @returns The IssueListHeader React element
*/
export function IssueListHeader({
projectPath,
openIssuesCount,
isLoading,
searchQuery,
filterState,
onSearchChange,
onFilterChange,
onRefresh
}: IssueListHeaderProps) {
return (
<div className="shrink-0 p-4 border-b border-border">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-muted">
<GitlabIcon className="h-5 w-5 text-orange-500" />
</div>
<div>
<h2 className="text-lg font-semibold text-foreground">
GitLab Issues
</h2>
<p className="text-xs text-muted-foreground">
{projectPath}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-xs">
{openIssuesCount} open
</Badge>
<Button
variant="ghost"
size="icon"
onClick={onRefresh}
disabled={isLoading}
>
<RefreshCw className={`h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
</Button>
</div>
</div>
{/* Filters */}
<div className="flex items-center gap-3">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search issues..."
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-9"
/>
</div>
<Select value={filterState} onValueChange={onFilterChange}>
<SelectTrigger className="w-32">
<Filter className="h-4 w-4 mr-2" />
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="opened">Open</SelectItem>
<SelectItem value="closed">Closed</SelectItem>
<SelectItem value="all">All</SelectItem>
</SelectContent>
</Select>
</div>
</div>
);
}
@@ -0,0 +1,91 @@
import { User, MessageCircle, Tag, Sparkles } from 'lucide-react';
import { Badge } from '../../ui/badge';
import { Button } from '../../ui/button';
import type { IssueListItemProps } from '../types';
// GitLab issue state colors and labels
const GITLAB_ISSUE_STATE_COLORS: Record<string, string> = {
opened: 'bg-green-500/10 text-green-500 border-green-500/20',
closed: 'bg-purple-500/10 text-purple-500 border-purple-500/20'
};
const GITLAB_ISSUE_STATE_LABELS: Record<string, string> = {
opened: 'Open',
closed: 'Closed'
};
/**
* Render a selectable GitLab issue list item showing state, title, author, counts, and an investigate action.
*
* @param issue - The GitLab issue data to display (contains state, iid, title, author, userNotesCount, labels).
* @param isSelected - Whether the item is visually marked as selected.
* @param onClick - Callback invoked when the item is clicked or activated via keyboard (Enter/Space).
* @param onInvestigate - Callback invoked when the investigate action button is clicked.
* @returns The React element representing the issue list item.
*/
export function IssueListItem({ issue, isSelected, onClick, onInvestigate }: IssueListItemProps) {
return (
<div
role="button"
tabIndex={0}
className={`group p-3 rounded-lg cursor-pointer transition-colors ${
isSelected
? 'bg-accent/50 border border-accent'
: 'hover:bg-muted/50 border border-transparent'
}`}
onClick={onClick}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onClick();
}
}}
>
<div className="flex items-start gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<Badge
variant="outline"
className={`text-xs ${GITLAB_ISSUE_STATE_COLORS[issue.state] || ''}`}
>
{GITLAB_ISSUE_STATE_LABELS[issue.state] || issue.state}
</Badge>
<span className="text-xs text-muted-foreground">#{issue.iid}</span>
</div>
<h4 className="text-sm font-medium text-foreground truncate">
{issue.title}
</h4>
<div className="flex items-center gap-3 mt-2 text-xs text-muted-foreground">
<div className="flex items-center gap-1">
<User className="h-3 w-3" />
{issue.author.username}
</div>
{issue.userNotesCount > 0 && (
<div className="flex items-center gap-1">
<MessageCircle className="h-3 w-3" />
{issue.userNotesCount}
</div>
)}
{issue.labels.length > 0 && (
<div className="flex items-center gap-1">
<Tag className="h-3 w-3" />
{issue.labels.length}
</div>
)}
</div>
</div>
<Button
variant="ghost"
size="icon"
className="opacity-0 group-hover:opacity-100 transition-opacity h-8 w-8"
onClick={(e) => {
e.stopPropagation();
onInvestigate();
}}
>
<Sparkles className="h-4 w-4" />
</Button>
</div>
</div>
);
}
@@ -0,0 +1,87 @@
import { useEffect, useCallback } from 'react';
import { useGitLabStore, investigateGitLabIssue } from '../../../stores/gitlab-store';
import { loadTasks } from '../../../stores/task-store';
import type { GitLabIssue } from '../../../../shared/types';
/**
* Manages GitLab issue investigation state and actions for a specific project.
*
* Subscribes to investigation progress, completion, and error events scoped to the provided `projectId`,
* updates the global store with status and results, and exposes functions to start and reset an investigation.
*
* @returns An object with:
* - `investigationStatus` — the current investigation phase, progress, and message.
* - `lastInvestigationResult` — the most recent investigation result object (success, taskId, etc.).
* - `startInvestigation` — a function `(issue: GitLabIssue, selectedNoteIds: number[]) => void` that begins an investigation for the given issue.
* - `resetInvestigationStatus` — a function `() => void` that resets the investigation status to idle.
*/
export function useGitLabInvestigation(projectId: string | undefined) {
const {
investigationStatus,
lastInvestigationResult,
setInvestigationStatus,
setInvestigationResult,
setError
} = useGitLabStore();
// Set up event listeners for investigation progress
useEffect(() => {
if (!projectId) return;
const cleanupProgress = window.electronAPI.onGitLabInvestigationProgress(
(eventProjectId, status) => {
if (eventProjectId === projectId) {
setInvestigationStatus(status);
}
}
);
const cleanupComplete = window.electronAPI.onGitLabInvestigationComplete(
(eventProjectId, result) => {
if (eventProjectId === projectId) {
setInvestigationResult(result);
// Refresh the task store so the new task appears on the Kanban board
if (result.success && result.taskId) {
loadTasks(projectId);
}
}
}
);
const cleanupError = window.electronAPI.onGitLabInvestigationError(
(eventProjectId, error) => {
if (eventProjectId === projectId) {
setError(error);
setInvestigationStatus({
phase: 'error',
progress: 0,
message: error
});
}
}
);
return () => {
cleanupProgress();
cleanupComplete();
cleanupError();
};
}, [projectId, setInvestigationStatus, setInvestigationResult, setError]);
const startInvestigation = useCallback((issue: GitLabIssue, selectedNoteIds: number[]) => {
if (projectId) {
investigateGitLabIssue(projectId, issue.iid, selectedNoteIds);
}
}, [projectId]);
const resetInvestigationStatus = useCallback(() => {
setInvestigationStatus({ phase: 'idle', progress: 0, message: '' });
}, [setInvestigationStatus]);
return {
investigationStatus,
lastInvestigationResult,
startInvestigation,
resetInvestigationStatus
};
}
@@ -0,0 +1,86 @@
import { useEffect, useCallback, useRef } from 'react';
import { useGitLabStore, loadGitLabIssues, checkGitLabConnection } from '../../../stores/gitlab-store';
import type { FilterState } from '../types';
/**
* Exposes GitLab issue state and related actions scoped to the given project.
*
* Provides current issues, sync status, loading/error state, selection and filtering state,
* helper selectors, and handlers to refresh or change filters for the specified project.
*
* @param projectId - The GitLab project identifier used to scope issues; pass `undefined` to disable loading for no project.
* @returns An object containing:
* - `issues` — all issues for the current project and filter
* - `syncStatus` — current synchronization/connection status with GitLab
* - `isLoading` — `true` when issues are being loaded
* - `error` — any error encountered while loading or syncing
* - `selectedIssueIid` — the currently selected issue IID, if any
* - `filterState` — current issue filter state
* - `selectIssue` — function to set the selected issue IID
* - `getFilteredIssues` — selector that returns issues after applying filters
* - `getOpenIssuesCount` — selector that returns the count of open issues
* - `handleRefresh` — reloads connection status and issues for the current project
* - `handleFilterChange` — updates the filter state and reloads issues for the new filter
*/
export function useGitLabIssues(projectId: string | undefined) {
const {
issues,
syncStatus,
isLoading,
error,
selectedIssueIid,
filterState,
selectIssue,
setFilterState,
getFilteredIssues,
getOpenIssuesCount
} = useGitLabStore();
// Track if we've checked connection for this mount
const hasCheckedRef = useRef(false);
// Always check connection when component mounts or projectId changes
useEffect(() => {
if (projectId) {
// Always check connection on mount (in case settings changed)
checkGitLabConnection(projectId);
hasCheckedRef.current = true;
}
}, [projectId]);
// Load issues when filter changes or after connection is established
useEffect(() => {
if (projectId && syncStatus?.connected) {
loadGitLabIssues(projectId, filterState);
}
}, [projectId, filterState, syncStatus?.connected]);
const handleRefresh = useCallback(() => {
if (projectId) {
// Re-check connection and reload issues
checkGitLabConnection(projectId);
loadGitLabIssues(projectId, filterState);
}
}, [projectId, filterState]);
const handleFilterChange = useCallback((state: FilterState) => {
setFilterState(state);
if (projectId) {
loadGitLabIssues(projectId, state);
}
}, [projectId, setFilterState]);
return {
issues,
syncStatus,
isLoading,
error,
selectedIssueIid,
filterState,
selectIssue,
getFilteredIssues,
getOpenIssuesCount,
handleRefresh,
handleFilterChange
};
}
@@ -0,0 +1,26 @@
import { useState, useMemo } from 'react';
import type { GitLabIssue } from '../../../../shared/types';
import { filterIssuesBySearch } from '../utils';
/**
* Provides state and memoized filtered results for searching a list of GitLab issues.
*
* @param issues - The array of GitLab issues to filter based on the current search query.
* @returns An object containing:
* - `searchQuery`: the current search string.
* - `setSearchQuery`: updater function to change the search string.
* - `filteredIssues`: the issues filtered according to `searchQuery`.
*/
export function useIssueFiltering(issues: GitLabIssue[]) {
const [searchQuery, setSearchQuery] = useState('');
const filteredIssues = useMemo(() => {
return filterIssuesBySearch(issues, searchQuery);
}, [issues, searchQuery]);
return {
searchQuery,
setSearchQuery,
filteredIssues
};
}
@@ -0,0 +1,34 @@
import type { GitLabIssue } from '../../../../shared/types';
/**
* Formats a date string into an en-US short month/day/year representation.
*
* @param dateString - A date string accepted by the JavaScript Date constructor
* @returns The formatted date using short month, numeric day, and numeric year (e.g., "Jan 2, 2006")
*/
export function formatDate(dateString: string): string {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
}
/**
* Filter a list of GitLab issues by a case-insensitive search query against title or description.
*
* @param issues - The array of issues to filter.
* @param searchQuery - The search text; when empty, the original `issues` array is returned unchanged.
* @returns The subset of `issues` whose title or description contains `searchQuery`, matching case-insensitively.
*/
export function filterIssuesBySearch(issues: GitLabIssue[], searchQuery: string): GitLabIssue[] {
if (!searchQuery) {
return issues;
}
const query = searchQuery.toLowerCase();
return issues.filter(issue =>
issue.title.toLowerCase().includes(query) ||
issue.description?.toLowerCase().includes(query)
);
}
@@ -0,0 +1,177 @@
import { useState, useEffect } from 'react';
import { Plus, AlertCircle } from 'lucide-react';
import { Button } from '../ui/button';
import { MergeRequestList } from './components/MergeRequestList';
import { CreateMergeRequestDialog } from './components/CreateMergeRequestDialog';
import type { GitLabMergeRequest } from '../../../shared/types';
interface GitLabMergeRequestsProps {
projectId: string;
}
/**
* Render a UI for browsing and managing GitLab merge requests for a project.
*
* Fetches merge requests for the given project and selected state filter, displays
* a list and detail panel, handles selection, refresh, error states, and creation
* of new merge requests via a dialog.
*
* @param projectId - The GitLab project identifier to load merge requests for
* @returns The rendered GitLab merge requests interface
*/
export function GitLabMergeRequests({ projectId }: GitLabMergeRequestsProps) {
const [mergeRequests, setMergeRequests] = useState<GitLabMergeRequest[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedMr, setSelectedMr] = useState<GitLabMergeRequest | null>(null);
const [stateFilter, setStateFilter] = useState<'opened' | 'closed' | 'merged' | 'all'>('opened');
const [showCreateDialog, setShowCreateDialog] = useState(false);
const fetchMergeRequests = async () => {
setIsLoading(true);
setError(null);
try {
const result = await window.electronAPI.getGitLabMergeRequests(projectId, stateFilter);
if (result.success && result.data) {
setMergeRequests(result.data);
} else {
setError(result.error || 'Failed to fetch merge requests');
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch merge requests');
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchMergeRequests();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [projectId, stateFilter]);
const handleSelectMr = (mr: GitLabMergeRequest) => {
setSelectedMr(mr);
};
const handleCreateSuccess = (mrIid: number) => {
fetchMergeRequests();
// Select the newly created MR
const newMr = mergeRequests.find(mr => mr.iid === mrIid);
if (newMr) {
setSelectedMr(newMr);
}
};
if (error) {
return (
<div className="flex flex-col items-center justify-center h-full p-8">
<AlertCircle className="h-12 w-12 text-destructive mb-4" />
<p className="text-sm text-muted-foreground text-center">{error}</p>
<Button variant="outline" onClick={fetchMergeRequests} className="mt-4">
Try Again
</Button>
</div>
);
}
return (
<div className="flex h-full">
{/* List Panel */}
<div className="w-80 border-r border-border flex flex-col">
<MergeRequestList
mergeRequests={mergeRequests}
isLoading={isLoading}
selectedMrIid={selectedMr?.iid || null}
onSelectMr={handleSelectMr}
onRefresh={fetchMergeRequests}
stateFilter={stateFilter}
onStateFilterChange={setStateFilter}
/>
<div className="p-2 border-t border-border">
<Button
onClick={() => setShowCreateDialog(true)}
className="w-full gap-2"
size="sm"
>
<Plus className="h-4 w-4" />
New Merge Request
</Button>
</div>
</div>
{/* Detail Panel */}
<div className="flex-1 p-6">
{selectedMr ? (
<div className="space-y-4">
<div className="flex items-start justify-between">
<div>
<h2 className="text-xl font-semibold text-foreground">
!{selectedMr.iid} {selectedMr.title}
</h2>
<p className="text-sm text-muted-foreground mt-1">
{selectedMr.sourceBranch} {selectedMr.targetBranch}
</p>
</div>
<span className={`px-2 py-1 rounded text-xs font-medium ${
selectedMr.state === 'opened' ? 'bg-success/20 text-success' :
selectedMr.state === 'merged' ? 'bg-info/20 text-info' :
'bg-destructive/20 text-destructive'
}`}>
{selectedMr.state}
</span>
</div>
{selectedMr.description && (
<div className="prose prose-sm dark:prose-invert max-w-none">
<p className="text-sm text-muted-foreground whitespace-pre-wrap">
{selectedMr.description}
</p>
</div>
)}
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<span>by {selectedMr.author.username}</span>
<span>Created {new Date(selectedMr.createdAt).toLocaleDateString()}</span>
{selectedMr.mergedAt && (
<span>Merged {new Date(selectedMr.mergedAt).toLocaleDateString()}</span>
)}
</div>
{selectedMr.labels.length > 0 && (
<div className="flex flex-wrap gap-2">
{selectedMr.labels.map((label) => (
<span
key={label}
className="px-2 py-1 rounded text-xs bg-muted text-muted-foreground"
>
{label}
</span>
))}
</div>
)}
<Button
variant="outline"
onClick={() => window.electronAPI.openExternal(selectedMr.webUrl)}
>
View on GitLab
</Button>
</div>
) : (
<div className="flex items-center justify-center h-full text-muted-foreground">
Select a merge request to view details
</div>
)}
</div>
{/* Create Dialog */}
<CreateMergeRequestDialog
open={showCreateDialog}
onOpenChange={setShowCreateDialog}
projectId={projectId}
onSuccess={handleCreateSuccess}
/>
</div>
);
}
@@ -0,0 +1,172 @@
import { useState } from 'react';
import { Loader2, GitPullRequest } from 'lucide-react';
import { Button } from '../../ui/button';
import { Input } from '../../ui/input';
import { Label } from '../../ui/label';
import { Textarea } from '../../ui/textarea';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '../../ui/dialog';
interface CreateMergeRequestDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
projectId: string;
defaultSourceBranch?: string;
defaultTargetBranch?: string;
onSuccess?: (mrIid: number) => void;
}
/**
* Render a modal dialog that collects information and creates a GitLab merge request.
*
* The dialog provides inputs for title, source branch, target branch, and an optional description;
* it validates required fields, calls the platform API to create the merge request, displays errors,
* and resets/closes on success.
*
* @param open - Whether the dialog is visible
* @param onOpenChange - Callback invoked when the dialog open state should change
* @param projectId - Identifier of the GitLab project where the merge request will be created
* @param defaultSourceBranch - Initial value for the source branch input (defaults to an empty string)
* @param defaultTargetBranch - Initial value for the target branch input (defaults to `"main"`)
* @param onSuccess - Optional callback called with the new merge request IID after successful creation
* @returns A React element that renders the "Create Merge Request" dialog
*/
export function CreateMergeRequestDialog({
open,
onOpenChange,
projectId,
defaultSourceBranch = '',
defaultTargetBranch = 'main',
onSuccess
}: CreateMergeRequestDialogProps) {
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [sourceBranch, setSourceBranch] = useState(defaultSourceBranch);
const [targetBranch, setTargetBranch] = useState(defaultTargetBranch);
const [isCreating, setIsCreating] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleCreate = async () => {
if (!title.trim() || !sourceBranch.trim() || !targetBranch.trim()) {
setError('Title, source branch, and target branch are required');
return;
}
setIsCreating(true);
setError(null);
try {
const result = await window.electronAPI.createGitLabMergeRequest(
projectId,
sourceBranch.trim(),
targetBranch.trim(),
title.trim(),
{ description: description.trim() || undefined }
);
if (result.success && result.data) {
onSuccess?.(result.data.iid);
onOpenChange(false);
// Reset form
setTitle('');
setDescription('');
setSourceBranch(defaultSourceBranch);
setTargetBranch(defaultTargetBranch);
} else {
setError(result.error || 'Failed to create merge request');
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create merge request');
} finally {
setIsCreating(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<GitPullRequest className="h-5 w-5" />
Create Merge Request
</DialogTitle>
<DialogDescription>
Create a new merge request in GitLab
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="title">Title</Label>
<Input
id="title"
placeholder="Merge request title"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="source">Source Branch</Label>
<Input
id="source"
placeholder="feature/my-feature"
value={sourceBranch}
onChange={(e) => setSourceBranch(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="target">Target Branch</Label>
<Input
id="target"
placeholder="main"
value={targetBranch}
onChange={(e) => setTargetBranch(e.target.value)}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description (optional)</Label>
<Textarea
id="description"
placeholder="Describe the changes in this merge request..."
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={4}
/>
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleCreate} disabled={isCreating}>
{isCreating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Creating...
</>
) : (
'Create Merge Request'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,97 @@
import { GitMerge, GitPullRequest, Lock, ExternalLink } from 'lucide-react';
import { cn } from '../../../lib/utils';
import type { GitLabMergeRequest } from '../../../../shared/types';
interface MergeRequestItemProps {
mr: GitLabMergeRequest;
isSelected: boolean;
onClick: () => void;
}
/**
* Render a clickable UI item representing a GitLab merge request.
*
* @param mr - The merge request to render. Expected fields: `iid`, `title`, `state`, `sourceBranch`, `targetBranch`, `labels`, `author.username`, `createdAt`, and `webUrl`.
* @param isSelected - Whether the item is visually presented as selected.
* @param onClick - Click handler invoked when the item is clicked.
* @returns The rendered merge request list item as a JSX element.
*/
export function MergeRequestItem({ mr, isSelected, onClick }: MergeRequestItemProps) {
const stateColors = {
opened: 'text-success',
closed: 'text-destructive',
merged: 'text-info',
locked: 'text-warning'
};
const stateIcons = {
opened: GitPullRequest,
closed: GitPullRequest,
merged: GitMerge,
locked: Lock
};
const StateIcon = stateIcons[mr.state] || GitPullRequest;
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
};
return (
<button
type="button"
onClick={onClick}
className={cn(
'w-full text-left p-3 rounded-lg border transition-colors',
isSelected
? 'border-primary bg-primary/5'
: 'border-transparent hover:bg-muted/50'
)}
>
<div className="flex items-start gap-3">
<StateIcon className={cn('h-5 w-5 mt-0.5 shrink-0', stateColors[mr.state])} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">!{mr.iid}</span>
<h4 className="text-sm font-medium text-foreground truncate">{mr.title}</h4>
</div>
<div className="flex items-center gap-2 mt-1 text-xs text-muted-foreground">
<span>{mr.sourceBranch}</span>
<span></span>
<span>{mr.targetBranch}</span>
</div>
<div className="flex items-center gap-2 mt-1">
{mr.labels.slice(0, 3).map((label) => (
<span
key={label}
className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground"
>
{label}
</span>
))}
{mr.labels.length > 3 && (
<span className="text-xs text-muted-foreground">
+{mr.labels.length - 3}
</span>
)}
</div>
<div className="flex items-center gap-2 mt-2 text-xs text-muted-foreground">
<span>by {mr.author.username}</span>
<span></span>
<span>{formatDate(mr.createdAt)}</span>
</div>
</div>
<a
href={mr.webUrl}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="text-muted-foreground hover:text-foreground"
>
<ExternalLink className="h-4 w-4" />
</a>
</div>
</button>
);
}
@@ -0,0 +1,121 @@
import { useState } from 'react';
import { Loader2, RefreshCw, GitPullRequest } from 'lucide-react';
import { Button } from '../../ui/button';
import { Input } from '../../ui/input';
import { ScrollArea } from '../../ui/scroll-area';
import { MergeRequestItem } from './MergeRequestItem';
import type { GitLabMergeRequest } from '../../../../shared/types';
interface MergeRequestListProps {
mergeRequests: GitLabMergeRequest[];
isLoading: boolean;
selectedMrIid: number | null;
onSelectMr: (mr: GitLabMergeRequest) => void;
onRefresh: () => void;
stateFilter: 'opened' | 'closed' | 'merged' | 'all';
onStateFilterChange: (state: 'opened' | 'closed' | 'merged' | 'all') => void;
}
/**
* Render a searchable, state-filtered list of GitLab merge requests with selection and refresh controls.
*
* Performs client-side filtering of the provided mergeRequests by title, source branch, target branch, or IID using the local search query.
*
* @param mergeRequests - The merge requests to display.
* @param isLoading - When true, shows loading states and disables the refresh control.
* @param selectedMrIid - The IID of the currently selected merge request, or `null` when none is selected.
* @param onSelectMr - Called with a merge request when the user selects an item.
* @param onRefresh - Called when the user clicks the refresh button.
* @param stateFilter - Current state filter; one of `"opened" | "closed" | "merged" | "all"`.
* @param onStateFilterChange - Called with a new state value when the user changes the state filter.
* @returns The rendered merge request list element.
*/
export function MergeRequestList({
mergeRequests,
isLoading,
selectedMrIid,
onSelectMr,
onRefresh,
stateFilter,
onStateFilterChange
}: MergeRequestListProps) {
const [searchQuery, setSearchQuery] = useState('');
const filteredMrs = mergeRequests.filter((mr) => {
const matchesSearch =
mr.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
mr.sourceBranch.toLowerCase().includes(searchQuery.toLowerCase()) ||
mr.targetBranch.toLowerCase().includes(searchQuery.toLowerCase()) ||
String(mr.iid).includes(searchQuery);
return matchesSearch;
});
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="p-4 border-b border-border space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-foreground flex items-center gap-2">
<GitPullRequest className="h-4 w-4" />
Merge Requests
</h3>
<Button
variant="ghost"
size="sm"
onClick={onRefresh}
disabled={isLoading}
className="h-7 px-2"
>
<RefreshCw className={`h-3 w-3 ${isLoading ? 'animate-spin' : ''}`} />
</Button>
</div>
<Input
placeholder="Search merge requests..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-8 text-sm"
/>
<div className="flex gap-1">
{(['opened', 'merged', 'closed', 'all'] as const).map((state) => (
<Button
key={state}
variant={stateFilter === state ? 'default' : 'ghost'}
size="sm"
onClick={() => onStateFilterChange(state)}
className="h-7 text-xs capitalize"
>
{state}
</Button>
))}
</div>
</div>
{/* List */}
<ScrollArea className="flex-1">
{isLoading && mergeRequests.length === 0 ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : filteredMrs.length === 0 ? (
<div className="py-8 text-center text-sm text-muted-foreground">
{searchQuery ? 'No matching merge requests' : 'No merge requests found'}
</div>
) : (
<div className="p-2 space-y-1">
{filteredMrs.map((mr) => (
<MergeRequestItem
key={mr.id}
mr={mr}
isSelected={mr.iid === selectedMrIid}
onClick={() => onSelectMr(mr)}
/>
))}
</div>
)}
</ScrollArea>
</div>
);
}
@@ -12,7 +12,8 @@ import type {
AutoBuildVersionInfo,
ProjectEnvConfig,
LinearSyncStatus,
GitHubSyncStatus
GitHubSyncStatus,
GitLabSyncStatus
} from '../../../../shared/types';
export interface UseProjectSettingsReturn {
@@ -55,6 +56,12 @@ export interface UseProjectSettingsReturn {
gitHubConnectionStatus: GitHubSyncStatus | null;
isCheckingGitHub: boolean;
// GitLab state
showGitLabToken: boolean;
setShowGitLabToken: React.Dispatch<React.SetStateAction<boolean>>;
gitLabConnectionStatus: GitLabSyncStatus | null;
isCheckingGitLab: boolean;
// Claude auth state
isCheckingClaudeAuth: boolean;
claudeAuthStatus: 'checking' | 'authenticated' | 'not_authenticated' | 'error';
@@ -74,6 +81,15 @@ export interface UseProjectSettingsReturn {
handleSave: (onClose: () => void) => Promise<void>;
}
/**
* Manages project settings state, environment config, connection statuses, and related action handlers used by the project settings UI.
*
* Provides local state and setter functions for settings, environment configuration, visibility toggles, connection statuses (GitHub, GitLab, Linear, Claude), loading/saving flags, and action handlers for initialize, update, save environment, Claude setup, and saving settings.
*
* @param project - The project whose settings and environment are being managed.
* @param open - Whether the project settings UI is currently open; controls lazy-loading and status checks that run only when open.
* @returns An object containing state values, setters, connection statuses, and action handler functions required by the project settings UI.
*/
export function useProjectSettings(
project: Project,
open: boolean
@@ -109,6 +125,11 @@ export function useProjectSettings(
const [gitHubConnectionStatus, setGitHubConnectionStatus] = useState<GitHubSyncStatus | null>(null);
const [isCheckingGitHub, setIsCheckingGitHub] = useState(false);
// GitLab state
const [showGitLabToken, setShowGitLabToken] = useState(false);
const [gitLabConnectionStatus, setGitLabConnectionStatus] = useState<GitLabSyncStatus | null>(null);
const [isCheckingGitLab, setIsCheckingGitLab] = useState(false);
// Claude auth state
const [isCheckingClaudeAuth, setIsCheckingClaudeAuth] = useState(false);
const [claudeAuthStatus, setClaudeAuthStatus] = useState<'checking' | 'authenticated' | 'not_authenticated' | 'error'>('checking');
@@ -236,6 +257,32 @@ export function useProjectSettings(
}
}, [envConfig?.githubEnabled, envConfig?.githubToken, envConfig?.githubRepo, project.id]);
// Check GitLab connection when token/project changes
useEffect(() => {
const checkGitLabConnection = async () => {
if (!envConfig?.gitlabEnabled || !envConfig.gitlabToken || !envConfig.gitlabProject) {
setGitLabConnectionStatus(null);
return;
}
setIsCheckingGitLab(true);
try {
const status = await window.electronAPI.checkGitLabConnection(project.id);
if (status.success && status.data) {
setGitLabConnectionStatus(status.data);
}
} catch {
setGitLabConnectionStatus({ connected: false, error: 'Failed to check connection' });
} finally {
setIsCheckingGitLab(false);
}
};
if (envConfig?.gitlabEnabled && envConfig.gitlabToken && envConfig.gitlabProject) {
checkGitLabConnection();
}
}, [envConfig?.gitlabEnabled, envConfig?.gitlabToken, envConfig?.gitlabProject, project.id]);
const toggleSection = (section: string) => {
setExpandedSections(prev => ({ ...prev, [section]: !prev[section] }));
};
@@ -389,6 +436,10 @@ export function useProjectSettings(
toggleSection,
gitHubConnectionStatus,
isCheckingGitHub,
showGitLabToken,
setShowGitLabToken,
gitLabConnectionStatus,
isCheckingGitLab,
isCheckingClaudeAuth,
claudeAuthStatus,
setClaudeAuthStatus,
@@ -402,4 +453,4 @@ export function useProjectSettings(
handleClaudeSetup,
handleSave
};
}
}
@@ -16,6 +16,20 @@ import {
Sparkles,
Monitor
} from 'lucide-react';
/**
* Renders a GitLab logo as an inline SVG.
*
* @param className - Optional CSS class applied to the root SVG element.
* @returns An SVG element containing the GitLab icon, styled with `currentColor`.
*/
function GitLabIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
<path d="M22.65 14.39L12 22.13 1.35 14.39a.84.84 0 0 1-.3-.94l1.22-3.78 2.44-7.51A.42.42 0 0 1 4.82 2a.43.43 0 0 1 .58 0 .42.42 0 0 1 .11.18l2.44 7.49h8.1l2.44-7.51A.42.42 0 0 1 18.6 2a.43.43 0 0 1 .58 0 .42.42 0 0 1 .11.18l2.44 7.51L23 13.45a.84.84 0 0 1-.35.94z"/>
</svg>
);
}
import {
FullScreenDialog,
FullScreenDialogContent,
@@ -72,6 +86,7 @@ const projectNavItems: NavItem<ProjectSettingsSection>[] = [
{ id: 'claude', label: 'Claude Auth', icon: Key, description: 'Claude authentication' },
{ id: 'linear', label: 'Linear', icon: Zap, description: 'Linear integration' },
{ id: 'github', label: 'GitHub', icon: Github, description: 'GitHub issues sync' },
{ id: 'gitlab', label: 'GitLab', icon: GitLabIcon, description: 'GitLab issues sync' },
{ id: 'memory', label: 'Memory', icon: Database, description: 'Graphiti memory backend' }
];
@@ -374,4 +389,4 @@ export function AppSettingsDialog({ open, onOpenChange, initialSection, initialP
</FullScreenDialogContent>
</FullScreenDialog>
);
}
}
@@ -9,7 +9,7 @@ import { SectionRouter } from './sections/SectionRouter';
import { createHookProxy } from './utils/hookProxyFactory';
import type { Project } from '../../../shared/types';
export type ProjectSettingsSection = 'general' | 'claude' | 'linear' | 'github' | 'memory';
export type ProjectSettingsSection = 'general' | 'claude' | 'linear' | 'github' | 'gitlab' | 'memory';
interface ProjectSettingsContentProps {
project: Project | undefined;
@@ -51,8 +51,9 @@ export function ProjectSettingsContent({
}
/**
* Inner component that uses the project settings hook.
* Separated to ensure the hook is only called when a project is selected.
* Render the project settings UI for a selected project and expose the project's settings hook to the parent when the settings dialog opens.
*
* @param onHookReady - Callback invoked with a proxy of the internal settings hook when `isOpen` becomes true, and invoked with `null` on cleanup (when the dialog closes or the component unmounts).
*/
function ProjectSettingsContentInner({
project,
@@ -93,6 +94,10 @@ function ProjectSettingsContentInner({
toggleSection: _toggleSection,
gitHubConnectionStatus,
isCheckingGitHub,
showGitLabToken,
setShowGitLabToken,
gitLabConnectionStatus,
isCheckingGitLab,
isCheckingClaudeAuth,
claudeAuthStatus,
showLinearImportModal,
@@ -141,6 +146,10 @@ function ProjectSettingsContentInner({
setShowGitHubToken={setShowGitHubToken}
gitHubConnectionStatus={gitHubConnectionStatus}
isCheckingGitHub={isCheckingGitHub}
showGitLabToken={showGitLabToken}
setShowGitLabToken={setShowGitLabToken}
gitLabConnectionStatus={gitLabConnectionStatus}
isCheckingGitLab={isCheckingGitLab}
isCheckingClaudeAuth={isCheckingClaudeAuth}
claudeAuthStatus={claudeAuthStatus}
linearConnectionStatus={linearConnectionStatus}
@@ -167,4 +176,4 @@ function ProjectSettingsContentInner({
/>
</>
);
}
}
@@ -0,0 +1,857 @@
import { useState, useEffect } from 'react';
import { RefreshCw, KeyRound, Loader2, CheckCircle2, AlertCircle, User, Lock, Globe, ChevronDown, GitBranch, Server } from 'lucide-react';
import { Input } from '../../ui/input';
import { Label } from '../../ui/label';
import { Switch } from '../../ui/switch';
import { Separator } from '../../ui/separator';
import { Button } from '../../ui/button';
import { PasswordInput } from '../../project-settings/PasswordInput';
import type { ProjectEnvConfig, GitLabSyncStatus } from '../../../../shared/types';
// Debug logging
const DEBUG = process.env.NODE_ENV === 'development' || process.env.DEBUG === 'true';
/**
* Conditionally emits debug messages to the console when debug mode is enabled.
*
* @param message - Textual message describing the event or state to log
* @param data - Optional supplemental value to log alongside the message
*/
function debugLog(message: string, data?: unknown) {
if (DEBUG) {
if (data !== undefined) {
console.warn(`[GitLabIntegration] ${message}`, data);
} else {
console.warn(`[GitLabIntegration] ${message}`);
}
}
}
interface GitLabProject {
pathWithNamespace: string;
description: string | null;
visibility: string;
}
interface GitLabIntegrationProps {
envConfig: ProjectEnvConfig | null;
updateEnvConfig: (updates: Partial<ProjectEnvConfig>) => void;
showGitLabToken: boolean;
setShowGitLabToken: React.Dispatch<React.SetStateAction<boolean>>;
gitLabConnectionStatus: GitLabSyncStatus | null;
isCheckingGitLab: boolean;
projectPath?: string;
}
/**
* Render the GitLab integration settings UI, including authentication (manual token or OAuth), project selection, branch selection, and connection status.
*
* @param props - GitLabIntegrationProps containing current environment config and callbacks to update it, token visibility controls, connection status, loading flags, and optional project path.
*/
export function GitLabIntegration({
envConfig,
updateEnvConfig,
showGitLabToken: _showGitLabToken,
setShowGitLabToken: _setShowGitLabToken,
gitLabConnectionStatus,
isCheckingGitLab,
projectPath
}: GitLabIntegrationProps) {
const [authMode, setAuthMode] = useState<'manual' | 'oauth' | 'oauth-success'>('manual');
const [oauthUsername, setOauthUsername] = useState<string | null>(null);
const [projects, setProjects] = useState<GitLabProject[]>([]);
const [isLoadingProjects, setIsLoadingProjects] = useState(false);
const [projectsError, setProjectsError] = useState<string | null>(null);
// Branch selection state
const [branches, setBranches] = useState<string[]>([]);
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
const [branchesError, setBranchesError] = useState<string | null>(null);
debugLog('Render - authMode:', authMode);
debugLog('Render - projectPath:', projectPath);
debugLog('Render - envConfig:', envConfig ? { gitlabEnabled: envConfig.gitlabEnabled, hasToken: !!envConfig.gitlabToken, defaultBranch: envConfig.defaultBranch } : null);
// Fetch projects when entering oauth-success mode
useEffect(() => {
if (authMode === 'oauth-success') {
fetchUserProjects();
}
}, [authMode]);
// Fetch branches when GitLab is enabled and project path is available
useEffect(() => {
debugLog(`useEffect[branches] - gitlabEnabled: ${envConfig?.gitlabEnabled}, projectPath: ${projectPath}`);
if (envConfig?.gitlabEnabled && projectPath) {
debugLog('useEffect[branches] - Triggering fetchBranches');
fetchBranches();
} else {
debugLog('useEffect[branches] - Skipping fetchBranches (conditions not met)');
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [envConfig?.gitlabEnabled, projectPath]);
const fetchBranches = async () => {
if (!projectPath) {
debugLog('fetchBranches: No projectPath, skipping');
return;
}
debugLog('fetchBranches: Starting with projectPath:', projectPath);
setIsLoadingBranches(true);
setBranchesError(null);
try {
debugLog('fetchBranches: Calling getGitBranches...');
const result = await window.electronAPI.getGitBranches(projectPath);
debugLog('fetchBranches: getGitBranches result:', { success: result.success, dataType: typeof result.data, dataLength: Array.isArray(result.data) ? result.data.length : 'N/A', error: result.error });
if (result.success && result.data) {
setBranches(result.data);
debugLog('fetchBranches: Loaded branches:', result.data.length);
// Auto-detect default branch if not set
if (!envConfig?.defaultBranch) {
debugLog('fetchBranches: No defaultBranch set, auto-detecting...');
const detectResult = await window.electronAPI.detectMainBranch(projectPath);
debugLog('fetchBranches: detectMainBranch result:', detectResult);
if (detectResult.success && detectResult.data) {
debugLog('fetchBranches: Auto-detected default branch:', detectResult.data);
updateEnvConfig({ defaultBranch: detectResult.data });
}
}
} else {
debugLog('fetchBranches: Failed -', result.error || 'No data returned');
setBranchesError(result.error || 'Failed to load branches');
}
} catch (err) {
debugLog('fetchBranches: Exception:', err);
setBranchesError(err instanceof Error ? err.message : 'Failed to load branches');
} finally {
setIsLoadingBranches(false);
}
};
const fetchUserProjects = async () => {
debugLog('Fetching user projects...');
setIsLoadingProjects(true);
setProjectsError(null);
try {
const hostname = envConfig?.gitlabInstanceUrl?.replace(/^https?:\/\//, '').replace(/\/$/, '');
const result = await window.electronAPI.listGitLabUserProjects(hostname);
debugLog('listGitLabUserProjects result:', result);
if (result.success && result.data?.projects) {
setProjects(result.data.projects);
debugLog('Loaded projects:', result.data.projects.length);
} else {
setProjectsError(result.error || 'Failed to load projects');
}
} catch (err) {
debugLog('Error fetching projects:', err);
setProjectsError(err instanceof Error ? err.message : 'Failed to load projects');
} finally {
setIsLoadingProjects(false);
}
};
if (!envConfig) {
debugLog('No envConfig, returning null');
return null;
}
const handleOAuthSuccess = async () => {
debugLog('handleOAuthSuccess called');
try {
const hostname = envConfig?.gitlabInstanceUrl?.replace(/^https?:\/\//, '').replace(/\/$/, '');
const tokenResult = await window.electronAPI.getGitLabToken(hostname);
if (tokenResult.success && tokenResult.data?.token) {
updateEnvConfig({ gitlabToken: tokenResult.data.token });
}
const userResult = await window.electronAPI.getGitLabUser(hostname);
if (userResult.success && userResult.data?.username) {
setOauthUsername(userResult.data.username);
}
setAuthMode('oauth-success');
} catch (err) {
debugLog('Error in OAuth success:', err);
}
};
const handleStartOAuth = async () => {
const hostname = envConfig?.gitlabInstanceUrl?.replace(/^https?:\/\//, '').replace(/\/$/, '');
const result = await window.electronAPI.startGitLabAuth(hostname);
if (result.success) {
// Poll for auth completion
const checkAuth = async () => {
const authResult = await window.electronAPI.checkGitLabAuth(hostname);
if (authResult.success && authResult.data?.authenticated) {
handleOAuthSuccess();
} else {
// Retry after delay
setTimeout(checkAuth, 2000);
}
};
setTimeout(checkAuth, 3000);
}
};
const handleSwitchToManual = () => {
setAuthMode('manual');
setOauthUsername(null);
};
const handleSwitchToOAuth = () => {
setAuthMode('oauth');
handleStartOAuth();
};
const handleSelectProject = (projectPath: string) => {
debugLog('Selected project:', projectPath);
updateEnvConfig({ gitlabProject: projectPath });
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label className="font-normal text-foreground">Enable GitLab Issues</Label>
<p className="text-xs text-muted-foreground">
Sync issues from GitLab and create tasks automatically
</p>
</div>
<Switch
checked={envConfig.gitlabEnabled}
onCheckedChange={(checked) => updateEnvConfig({ gitlabEnabled: checked })}
/>
</div>
{envConfig.gitlabEnabled && (
<>
{/* Instance URL */}
<InstanceUrlInput
value={envConfig.gitlabInstanceUrl || 'https://gitlab.com'}
onChange={(value) => updateEnvConfig({ gitlabInstanceUrl: value })}
/>
{/* OAuth Success State */}
{authMode === 'oauth-success' && (
<div className="space-y-4">
<div className="rounded-lg border border-success/30 bg-success/10 p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<CheckCircle2 className="h-5 w-5 text-success" />
<div>
<p className="text-sm font-medium text-success">Connected via GitLab CLI</p>
{oauthUsername && (
<p className="text-xs text-success/80 flex items-center gap-1 mt-0.5">
<User className="h-3 w-3" />
Authenticated as {oauthUsername}
</p>
)}
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={handleSwitchToManual}
className="text-xs"
>
Use Different Token
</Button>
</div>
</div>
{/* Project Dropdown */}
<ProjectDropdown
projects={projects}
selectedProject={envConfig.gitlabProject || ''}
isLoading={isLoadingProjects}
error={projectsError}
onSelect={handleSelectProject}
onRefresh={fetchUserProjects}
onManualEntry={() => setAuthMode('manual')}
/>
</div>
)}
{/* OAuth Flow */}
{authMode === 'oauth' && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium text-foreground">GitLab Authentication</Label>
<Button
variant="ghost"
size="sm"
onClick={handleSwitchToManual}
>
Use Manual Token
</Button>
</div>
<div className="rounded-lg border border-info/30 bg-info/10 p-4">
<div className="flex items-center gap-3">
<Loader2 className="h-5 w-5 text-info animate-spin" />
<div>
<p className="text-sm font-medium text-foreground">Authenticating with glab CLI...</p>
<p className="text-xs text-muted-foreground mt-1">
A browser window should open for you to log in.
</p>
</div>
</div>
</div>
</div>
)}
{/* Manual Token Entry */}
{authMode === 'manual' && (
<>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium text-foreground">Personal Access Token</Label>
<Button
variant="outline"
size="sm"
onClick={handleSwitchToOAuth}
className="gap-2"
>
<KeyRound className="h-3 w-3" />
Use OAuth Instead
</Button>
</div>
<p className="text-xs text-muted-foreground">
Create a token with <code className="px-1 bg-muted rounded">api</code> scope from{' '}
<a
href={`${envConfig.gitlabInstanceUrl || 'https://gitlab.com'}/-/user_settings/personal_access_tokens`}
target="_blank"
rel="noopener noreferrer"
className="text-info hover:underline"
>
GitLab Settings
</a>
</p>
<PasswordInput
value={envConfig.gitlabToken || ''}
onChange={(value) => updateEnvConfig({ gitlabToken: value })}
placeholder="glpat-xxxxxxxxxxxxxxxxxxxx"
/>
</div>
<ProjectInput
value={envConfig.gitlabProject || ''}
onChange={(value) => updateEnvConfig({ gitlabProject: value })}
/>
</>
)}
{envConfig.gitlabToken && envConfig.gitlabProject && (
<ConnectionStatus
isChecking={isCheckingGitLab}
connectionStatus={gitLabConnectionStatus}
/>
)}
{gitLabConnectionStatus?.connected && <IssuesAvailableInfo />}
<Separator />
{/* Default Branch Selector */}
{projectPath && (
<BranchSelector
branches={branches}
selectedBranch={envConfig.defaultBranch || ''}
isLoading={isLoadingBranches}
error={branchesError}
onSelect={(branch) => updateEnvConfig({ defaultBranch: branch })}
onRefresh={fetchBranches}
/>
)}
<Separator />
<AutoSyncToggle
enabled={envConfig.gitlabAutoSync || false}
onToggle={(checked) => updateEnvConfig({ gitlabAutoSync: checked })}
/>
</>
)}
</div>
);
}
interface InstanceUrlInputProps {
value: string;
onChange: (value: string) => void;
}
/**
* Renders an input control for configuring the GitLab instance URL.
*
* @param value - The current GitLab instance URL (e.g., `https://gitlab.com`) or an empty string.
* @param onChange - Callback invoked with the new URL when the input changes.
* @returns The rendered input element for configuring the GitLab instance URL.
*/
function InstanceUrlInput({ value, onChange }: InstanceUrlInputProps) {
return (
<div className="space-y-2">
<div className="flex items-center gap-2">
<Server className="h-4 w-4 text-muted-foreground" />
<Label className="text-sm font-medium text-foreground">GitLab Instance</Label>
</div>
<p className="text-xs text-muted-foreground">
Use <code className="px-1 bg-muted rounded">https://gitlab.com</code> or your self-hosted instance URL
</p>
<Input
placeholder="https://gitlab.com"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
</div>
);
}
interface ProjectDropdownProps {
projects: GitLabProject[];
selectedProject: string;
isLoading: boolean;
error: string | null;
onSelect: (projectPath: string) => void;
onRefresh: () => void;
onManualEntry: () => void;
}
/**
* Renders a searchable dropdown to choose a GitLab project with refresh and manual-entry options.
*
* Displays the currently selected project (with visibility icon), a refresh button, an "Enter Manually" action,
* and a dropdown that shows searchable project results. Handles loading and error states and exposes callbacks
* for selecting a project, refreshing the project list, and switching to manual entry.
*
* @param projects - List of available GitLab projects to show in the dropdown.
* @param selectedProject - The currently selected project's pathWithNamespace, if any.
* @param isLoading - When true, the component shows a loading state and disables interactions.
* @param error - Optional error message to display above the dropdown.
* @param onSelect - Callback invoked with the selected project's pathWithNamespace.
* @param onRefresh - Callback invoked when the refresh button is clicked.
* @param onManualEntry - Callback invoked when the user chooses to enter a project manually.
* @returns The project selection dropdown element.
*/
function ProjectDropdown({
projects,
selectedProject,
isLoading,
error,
onSelect,
onRefresh,
onManualEntry
}: ProjectDropdownProps) {
const [isOpen, setIsOpen] = useState(false);
const [filter, setFilter] = useState('');
const filteredProjects = projects.filter(project =>
project.pathWithNamespace.toLowerCase().includes(filter.toLowerCase()) ||
(project.description?.toLowerCase().includes(filter.toLowerCase()))
);
const selectedProjectData = projects.find(p => p.pathWithNamespace === selectedProject);
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium text-foreground">Project</Label>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={onRefresh}
disabled={isLoading}
className="h-7 px-2"
>
<RefreshCw className={`h-3 w-3 ${isLoading ? 'animate-spin' : ''}`} />
</Button>
<Button
variant="ghost"
size="sm"
onClick={onManualEntry}
className="h-7 text-xs"
>
Enter Manually
</Button>
</div>
</div>
{error && (
<div className="flex items-center gap-2 text-xs text-destructive">
<AlertCircle className="h-3 w-3" />
{error}
</div>
)}
<div className="relative">
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
disabled={isLoading}
className="w-full flex items-center justify-between px-3 py-2 text-sm border border-input rounded-md bg-background hover:bg-accent hover:text-accent-foreground disabled:opacity-50"
>
{isLoading ? (
<span className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Loading projects...
</span>
) : selectedProject ? (
<span className="flex items-center gap-2">
{selectedProjectData?.visibility === 'private' ? (
<Lock className="h-3 w-3 text-muted-foreground" />
) : (
<Globe className="h-3 w-3 text-muted-foreground" />
)}
{selectedProject}
</span>
) : (
<span className="text-muted-foreground">Select a project...</span>
)}
<ChevronDown className={`h-4 w-4 text-muted-foreground transition-transform ${isOpen ? 'rotate-180' : ''}`} />
</button>
{isOpen && !isLoading && (
<div className="absolute z-50 w-full mt-1 bg-popover border border-border rounded-md shadow-lg max-h-64 overflow-hidden">
<div className="p-2 border-b border-border">
<Input
placeholder="Search projects..."
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="h-8 text-sm"
autoFocus
/>
</div>
<div className="max-h-48 overflow-y-auto">
{filteredProjects.length === 0 ? (
<div className="px-3 py-4 text-sm text-muted-foreground text-center">
{filter ? 'No matching projects' : 'No projects found'}
</div>
) : (
filteredProjects.map((project) => (
<button
key={project.pathWithNamespace}
type="button"
onClick={() => {
onSelect(project.pathWithNamespace);
setIsOpen(false);
setFilter('');
}}
className={`w-full px-3 py-2 text-left hover:bg-accent flex items-start gap-2 ${
project.pathWithNamespace === selectedProject ? 'bg-accent' : ''
}`}
>
{project.visibility === 'private' ? (
<Lock className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
) : (
<Globe className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
)}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{project.pathWithNamespace}</p>
{project.description && (
<p className="text-xs text-muted-foreground truncate">{project.description}</p>
)}
</div>
</button>
))
)}
</div>
</div>
)}
</div>
{selectedProject && (
<p className="text-xs text-muted-foreground">
Selected: <code className="px-1 bg-muted rounded">{selectedProject}</code>
</p>
)}
</div>
);
}
interface ProjectInputProps {
value: string;
onChange: (value: string) => void;
}
/**
* Renders an input for specifying a GitLab project path in "group/project" format.
*
* @param value - Current project path (e.g., `gitlab-org/gitlab`)
* @param onChange - Callback invoked with the new project path when the input changes
* @returns The controlled input UI for entering a GitLab project identifier
*/
function ProjectInput({ value, onChange }: ProjectInputProps) {
return (
<div className="space-y-2">
<Label className="text-sm font-medium text-foreground">Project</Label>
<p className="text-xs text-muted-foreground">
Format: <code className="px-1 bg-muted rounded">group/project</code> (e.g., gitlab-org/gitlab)
</p>
<Input
placeholder="group/project"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
</div>
);
}
interface ConnectionStatusProps {
isChecking: boolean;
connectionStatus: GitLabSyncStatus | null;
}
/**
* Render a compact connection status panel for the GitLab integration.
*
* Displays a title, a short status line that shows "Checking..." while a check is in progress,
* the connected project path when connected, or an error / "Not connected" message otherwise.
*
* @param props.isChecking - Whether a connection check is currently in progress; when `true` the panel shows a loading indicator and "Checking...".
* @param props.connectionStatus - Connection details used to populate the status line:
* - when `.connected` is `true`, `.projectPathWithNamespace` is shown and optional `.projectDescription` is displayed below;
* - when `.connected` is `false`, `.error` (if present) is shown, otherwise "Not connected" is displayed.
* @returns The rendered connection status UI block.
*/
function ConnectionStatus({ isChecking, connectionStatus }: ConnectionStatusProps) {
return (
<div className="rounded-lg border border-border bg-muted/30 p-3">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-foreground">Connection Status</p>
<p className="text-xs text-muted-foreground">
{isChecking ? 'Checking...' :
connectionStatus?.connected
? `Connected to ${connectionStatus.projectPathWithNamespace}`
: connectionStatus?.error || 'Not connected'}
</p>
{connectionStatus?.connected && connectionStatus.projectDescription && (
<p className="text-xs text-muted-foreground mt-1 italic">
{connectionStatus.projectDescription}
</p>
)}
</div>
{isChecking ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
) : connectionStatus?.connected ? (
<CheckCircle2 className="h-4 w-4 text-success" />
) : (
<AlertCircle className="h-4 w-4 text-warning" />
)}
</div>
</div>
);
}
/**
* Displays an informational panel that indicates GitLab Issues are available and how to access them.
*
* @returns A React element rendering an informational block about accessing GitLab Issues from the sidebar.
*/
function IssuesAvailableInfo() {
return (
<div className="rounded-lg border border-info/30 bg-info/5 p-3">
<div className="flex items-start gap-3">
<svg className="h-5 w-5 text-info mt-0.5" viewBox="0 0 24 24" fill="currentColor">
<path d="M22.65 14.39L12 22.13 1.35 14.39a.84.84 0 0 1-.3-.94l1.22-3.78 2.44-7.51A.42.42 0 0 1 4.82 2a.43.43 0 0 1 .58 0 .42.42 0 0 1 .11.18l2.44 7.49h8.1l2.44-7.51A.42.42 0 0 1 18.6 2a.43.43 0 0 1 .58 0 .42.42 0 0 1 .11.18l2.44 7.51L23 13.45a.84.84 0 0 1-.35.94z"/>
</svg>
<div className="flex-1">
<p className="text-sm font-medium text-foreground">Issues Available</p>
<p className="text-xs text-muted-foreground mt-1">
Access GitLab Issues from the sidebar to view, investigate, and create tasks from issues.
</p>
</div>
</div>
</div>
);
}
interface AutoSyncToggleProps {
enabled: boolean;
onToggle: (checked: boolean) => void;
}
/**
* Render a labeled toggle that controls automatic issue synchronization when a project loads.
*
* @param enabled - Whether auto-sync is currently enabled
* @param onToggle - Callback invoked with the new checked state when the toggle changes
*/
function AutoSyncToggle({ enabled, onToggle }: AutoSyncToggleProps) {
return (
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<div className="flex items-center gap-2">
<RefreshCw className="h-4 w-4 text-info" />
<Label className="font-normal text-foreground">Auto-Sync on Load</Label>
</div>
<p className="text-xs text-muted-foreground pl-6">
Automatically fetch issues when the project loads
</p>
</div>
<Switch checked={enabled} onCheckedChange={onToggle} />
</div>
);
}
interface BranchSelectorProps {
branches: string[];
selectedBranch: string;
isLoading: boolean;
error: string | null;
onSelect: (branch: string) => void;
onRefresh: () => void;
}
/**
* Render a searchable dropdown UI for selecting the repository default branch.
*
* Renders the current selection (or an auto-detect option), a refresh control, loading and error states, and a searchable list of branches.
*
* @param branches - Available branch names to display in the list.
* @param selectedBranch - Currently selected branch name; empty string or undefined indicates auto-detect (main/master).
* @param isLoading - When true, disables interactions and shows a loading state.
* @param error - Optional error message to show beneath the label.
* @param onSelect - Callback invoked with the chosen branch name (empty string to select auto-detect).
* @param onRefresh - Callback invoked when the refresh button is pressed to reload branch data.
* @returns The branch selector React element.
*/
function BranchSelector({
branches,
selectedBranch,
isLoading,
error,
onSelect,
onRefresh
}: BranchSelectorProps) {
const [isOpen, setIsOpen] = useState(false);
const [filter, setFilter] = useState('');
const filteredBranches = branches.filter(branch =>
branch.toLowerCase().includes(filter.toLowerCase())
);
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<div className="flex items-center gap-2">
<GitBranch className="h-4 w-4 text-info" />
<Label className="text-sm font-medium text-foreground">Default Branch</Label>
</div>
<p className="text-xs text-muted-foreground pl-6">
Base branch for creating task worktrees
</p>
</div>
<Button
variant="ghost"
size="sm"
onClick={onRefresh}
disabled={isLoading}
className="h-7 px-2"
>
<RefreshCw className={`h-3 w-3 ${isLoading ? 'animate-spin' : ''}`} />
</Button>
</div>
{error && (
<div className="flex items-center gap-2 text-xs text-destructive pl-6">
<AlertCircle className="h-3 w-3" />
{error}
</div>
)}
<div className="relative pl-6">
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
disabled={isLoading}
className="w-full flex items-center justify-between px-3 py-2 text-sm border border-input rounded-md bg-background hover:bg-accent hover:text-accent-foreground disabled:opacity-50"
>
{isLoading ? (
<span className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Loading branches...
</span>
) : selectedBranch ? (
<span className="flex items-center gap-2">
<GitBranch className="h-3 w-3 text-muted-foreground" />
{selectedBranch}
</span>
) : (
<span className="text-muted-foreground">Auto-detect (main/master)</span>
)}
<ChevronDown className={`h-4 w-4 text-muted-foreground transition-transform ${isOpen ? 'rotate-180' : ''}`} />
</button>
{isOpen && !isLoading && (
<div className="absolute z-50 w-full mt-1 bg-popover border border-border rounded-md shadow-lg max-h-64 overflow-hidden">
<div className="p-2 border-b border-border">
<Input
placeholder="Search branches..."
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="h-8 text-sm"
autoFocus
/>
</div>
<button
type="button"
onClick={() => {
onSelect('');
setIsOpen(false);
setFilter('');
}}
className={`w-full px-3 py-2 text-left hover:bg-accent flex items-center gap-2 ${
!selectedBranch ? 'bg-accent' : ''
}`}
>
<span className="text-sm text-muted-foreground italic">Auto-detect (main/master)</span>
</button>
<div className="max-h-40 overflow-y-auto border-t border-border">
{filteredBranches.length === 0 ? (
<div className="px-3 py-4 text-sm text-muted-foreground text-center">
{filter ? 'No matching branches' : 'No branches found'}
</div>
) : (
filteredBranches.map((branch) => (
<button
key={branch}
type="button"
onClick={() => {
onSelect(branch);
setIsOpen(false);
setFilter('');
}}
className={`w-full px-3 py-2 text-left hover:bg-accent flex items-center gap-2 ${
branch === selectedBranch ? 'bg-accent' : ''
}`}
>
<GitBranch className="h-3 w-3 text-muted-foreground" />
<span className="text-sm">{branch}</span>
</button>
))
)}
</div>
</div>
)}
</div>
{selectedBranch && (
<p className="text-xs text-muted-foreground pl-6">
All new tasks will branch from <code className="px-1 bg-muted rounded">{selectedBranch}</code>
</p>
)}
</div>
);
}
@@ -1,10 +1,11 @@
import type { Project, ProjectSettings as ProjectSettingsType, AutoBuildVersionInfo, ProjectEnvConfig, LinearSyncStatus, GitHubSyncStatus } from '../../../../shared/types';
import type { Project, ProjectSettings as ProjectSettingsType, AutoBuildVersionInfo, ProjectEnvConfig, LinearSyncStatus, GitHubSyncStatus, GitLabSyncStatus } from '../../../../shared/types';
import { SettingsSection } from '../SettingsSection';
import { GeneralSettings } from '../../project-settings/GeneralSettings';
import { EnvironmentSettings } from '../../project-settings/EnvironmentSettings';
import { SecuritySettings } from '../../project-settings/SecuritySettings';
import { LinearIntegration } from '../integrations/LinearIntegration';
import { GitHubIntegration } from '../integrations/GitHubIntegration';
import { GitLabIntegration } from '../integrations/GitLabIntegration';
import { InitializationGuard } from '../common/InitializationGuard';
import type { ProjectSettingsSection } from '../ProjectSettingsContent';
@@ -30,6 +31,10 @@ interface SectionRouterProps {
setShowGitHubToken: React.Dispatch<React.SetStateAction<boolean>>;
gitHubConnectionStatus: GitHubSyncStatus | null;
isCheckingGitHub: boolean;
showGitLabToken: boolean;
setShowGitLabToken: React.Dispatch<React.SetStateAction<boolean>>;
gitLabConnectionStatus: GitLabSyncStatus | null;
isCheckingGitLab: boolean;
isCheckingClaudeAuth: boolean;
claudeAuthStatus: 'checking' | 'authenticated' | 'not_authenticated' | 'error';
linearConnectionStatus: LinearSyncStatus | null;
@@ -41,8 +46,12 @@ interface SectionRouterProps {
}
/**
* Routes to the appropriate settings section based on activeSection.
* Handles initialization guards and section-specific configurations.
* Render the settings UI for the currently selected section, applying initialization guards where required.
*
* Renders the appropriate settings section (general, claude, linear, github, gitlab, memory) configured
* with the provided project, environment, integration, and visibility props.
*
* @returns The React element for the active settings section, or `null` if the section is unrecognized.
*/
export function SectionRouter({
activeSection,
@@ -66,6 +75,10 @@ export function SectionRouter({
setShowGitHubToken,
gitHubConnectionStatus,
isCheckingGitHub,
showGitLabToken,
setShowGitLabToken,
gitLabConnectionStatus,
isCheckingGitLab,
isCheckingClaudeAuth,
claudeAuthStatus,
linearConnectionStatus,
@@ -171,6 +184,30 @@ export function SectionRouter({
</SettingsSection>
);
case 'gitlab':
return (
<SettingsSection
title="GitLab Integration"
description="Connect to GitLab for issue tracking"
>
<InitializationGuard
initialized={!!project.autoBuildPath}
title="GitLab Integration"
description="Sync with GitLab Issues"
>
<GitLabIntegration
envConfig={envConfig}
updateEnvConfig={updateEnvConfig}
showGitLabToken={showGitLabToken}
setShowGitLabToken={setShowGitLabToken}
gitLabConnectionStatus={gitLabConnectionStatus}
isCheckingGitLab={isCheckingGitLab}
projectPath={project.path}
/>
</InitializationGuard>
</SettingsSection>
);
case 'memory':
return (
<SettingsSection
@@ -199,4 +236,4 @@ export function SectionRouter({
default:
return null;
}
}
}
@@ -0,0 +1,223 @@
import { create } from 'zustand';
import type {
GitLabIssue,
GitLabSyncStatus,
GitLabInvestigationStatus,
GitLabInvestigationResult
} from '../../shared/types';
interface GitLabState {
// Data
issues: GitLabIssue[];
syncStatus: GitLabSyncStatus | null;
// UI State
isLoading: boolean;
error: string | null;
selectedIssueIid: number | null;
filterState: 'opened' | 'closed' | 'all';
// Investigation state
investigationStatus: GitLabInvestigationStatus;
lastInvestigationResult: GitLabInvestigationResult | null;
// Actions
setIssues: (issues: GitLabIssue[]) => void;
addIssue: (issue: GitLabIssue) => void;
updateIssue: (issueIid: number, updates: Partial<GitLabIssue>) => void;
setSyncStatus: (status: GitLabSyncStatus | null) => void;
setLoading: (loading: boolean) => void;
setError: (error: string | null) => void;
selectIssue: (issueIid: number | null) => void;
setFilterState: (state: 'opened' | 'closed' | 'all') => void;
setInvestigationStatus: (status: GitLabInvestigationStatus) => void;
setInvestigationResult: (result: GitLabInvestigationResult | null) => void;
clearIssues: () => void;
// Selectors
getSelectedIssue: () => GitLabIssue | null;
getFilteredIssues: () => GitLabIssue[];
getOpenIssuesCount: () => number;
}
export const useGitLabStore = create<GitLabState>((set, get) => ({
// Initial state
issues: [],
syncStatus: null,
isLoading: false,
error: null,
selectedIssueIid: null,
filterState: 'opened',
investigationStatus: {
phase: 'idle',
progress: 0,
message: ''
},
lastInvestigationResult: 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
)
})),
setSyncStatus: (syncStatus) => set({ syncStatus }),
setLoading: (isLoading) => set({ isLoading }),
setError: (error) => set({ error, isLoading: false }),
selectIssue: (selectedIssueIid) => set({ selectedIssueIid }),
setFilterState: (filterState) => set({ filterState }),
setInvestigationStatus: (investigationStatus) => set({ investigationStatus }),
setInvestigationResult: (lastInvestigationResult) => set({ lastInvestigationResult }),
clearIssues: () => set({
issues: [],
syncStatus: null,
selectedIssueIid: null,
error: null,
investigationStatus: { phase: 'idle', progress: 0, message: '' },
lastInvestigationResult: null
}),
// 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 and update the GitLab store accordingly.
*
* The function sets the store's loading state and clears any prior error, optionally
* synchronizes the store's filter state, requests issues via the Electron API,
* and updates the store with the retrieved issues or an error message. Loading is
* cleared when the operation completes.
*
* @param projectId - The GitLab project identifier to load issues from
* @param state - Optional issue state to request and to synchronize with the store's filter (`'opened' | 'closed' | 'all'`)
*/
export async function loadGitLabIssues(projectId: string, state?: 'opened' | 'closed' | 'all'): Promise<void> {
const store = useGitLabStore.getState();
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);
if (result.success && result.data) {
store.setIssues(result.data);
} else {
store.setError(result.error || 'Failed to load GitLab issues');
}
} catch (error) {
store.setError(error instanceof Error ? error.message : 'Unknown error');
} finally {
store.setLoading(false);
}
}
/**
* Checks the GitLab connection for a project and updates the store with the resulting sync status or error.
*
* @param projectId - The GitLab project identifier
* @returns The GitLab sync status when the check succeeds, `null` otherwise
*/
export async function checkGitLabConnection(projectId: string): Promise<GitLabSyncStatus | null> {
const store = useGitLabStore.getState();
try {
const result = await window.electronAPI.checkGitLabConnection(projectId);
if (result.success && result.data) {
store.setSyncStatus(result.data);
return result.data;
} else {
store.setError(result.error || 'Failed to check GitLab connection');
return null;
}
} catch (error) {
store.setError(error instanceof Error ? error.message : 'Unknown error');
return null;
}
}
/**
* Starts an investigation for a GitLab issue by updating the investigation state and invoking the backend investigator.
*
* Updates the store to a fetching phase (initial progress/message), clears any previous investigation result, and calls the Electron API to perform the investigation.
*
* @param projectId - The GitLab project ID containing the issue
* @param issueIid - The internal ID (IID) of the issue to investigate
* @param selectedNoteIds - Optional array of note IDs to include in the investigation
*/
export function investigateGitLabIssue(projectId: string, issueIid: number, selectedNoteIds?: number[]): void {
const store = useGitLabStore.getState();
store.setInvestigationStatus({
phase: 'fetching',
issueIid,
progress: 0,
message: 'Starting investigation...'
});
store.setInvestigationResult(null);
window.electronAPI.investigateGitLabIssue(projectId, issueIid, selectedNoteIds);
}
/**
* Imports the specified GitLab issues into the application and updates the GitLab store state.
*
* This sets the store's loading state while the import runs and updates the store's error on failure.
*
* @param projectId - The GitLab project identifier to import issues from
* @param issueIids - Array of GitLab issue IIDs to import
* @returns `true` if the import succeeded, `false` otherwise
*/
export async function importGitLabIssues(
projectId: string,
issueIids: number[]
): Promise<boolean> {
const store = useGitLabStore.getState();
store.setLoading(true);
try {
const result = await window.electronAPI.importGitLabIssues(projectId, issueIids);
if (result.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);
}
}