Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f0a490c5af |
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+257
@@ -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>
|
||||
);
|
||||
}
|
||||
+172
@@ -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>
|
||||
);
|
||||
}
|
||||
+97
@@ -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>
|
||||
);
|
||||
}
|
||||
+121
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user