diff --git a/apps/desktop/src/main/ai/agent/worker.ts b/apps/desktop/src/main/ai/agent/worker.ts index f03bb19d20..b018c22401 100644 --- a/apps/desktop/src/main/ai/agent/worker.ts +++ b/apps/desktop/src/main/ai/agent/worker.ts @@ -352,6 +352,11 @@ async function runSingleSession( oauthTokenFilePath: baseSession.oauthTokenFilePath, }); } catch (error) { + // Log the error to task_logs.json before cleanup + if (logWriter) { + const errorMsg = error instanceof Error ? error.message : String(error); + logWriter.logText(errorMsg, phase, 'error'); + } // Ensure log cleanup happens on failure if (logWriter && !skipPhaseLogging) logWriter.endPhase(phase, false); if (logWriter) logWriter.setSubtask(undefined); @@ -430,6 +435,10 @@ async function run(): Promise { } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); postError(`Agent session failed: ${message}`); + // Write to task_logs.json so the UI shows the error + if (logWriter) { + logWriter.logText(`Agent session failed: ${message}`, undefined, 'error'); + } } finally { // Cleanup MCP clients if (mcpClients.length > 0) { @@ -668,7 +677,13 @@ async function runBuildOrchestrator( }); orchestrator.on('error', (error: Error, phase: string) => { - postLog(`Error in ${phase} phase: ${error.message}`); + const errorMsg = `Error in ${phase} phase: ${error.message}`; + postLog(errorMsg); + postError(errorMsg); + // Also write to task_logs.json so the UI shows the error detail + if (logWriter) { + logWriter.logText(error.message, phase as Phase, 'error'); + } }); const outcome = await orchestrator.run(); diff --git a/apps/desktop/src/main/ai/config/agent-configs.ts b/apps/desktop/src/main/ai/config/agent-configs.ts index 0fe5aae9f1..c0c13543a2 100644 --- a/apps/desktop/src/main/ai/config/agent-configs.ts +++ b/apps/desktop/src/main/ai/config/agent-configs.ts @@ -88,6 +88,25 @@ export const MEMORY_MCP_TOOLS = [ /** @deprecated Use MEMORY_MCP_TOOLS instead */ export const GRAPHITI_MCP_TOOLS = MEMORY_MCP_TOOLS; +/** JIRA MCP tools for issue tracking (when JIRA is configured) */ +export const JIRA_TOOLS = [ + 'mcp__jira__list_projects', + 'mcp__jira__get_issue', + 'mcp__jira__create_issue', + 'mcp__jira__update_issue', + 'mcp__jira__search_issues', + 'mcp__jira__add_comment', + 'mcp__jira__get_transitions', + 'mcp__jira__transition_issue', +] as const; + +/** Vault MCP tools for external vault access */ +export const VAULT_TOOLS = [ + 'mcp__vault__read_file', + 'mcp__vault__list_directory', + 'mcp__vault__search_files', +] as const; + // ============================================================================= // Browser Automation MCP Tools (QA agents only) // ============================================================================= diff --git a/apps/desktop/src/main/ai/logging/task-log-writer.ts b/apps/desktop/src/main/ai/logging/task-log-writer.ts index 6c8ea7768e..e5940543c1 100644 --- a/apps/desktop/src/main/ai/logging/task-log-writer.ts +++ b/apps/desktop/src/main/ai/logging/task-log-writer.ts @@ -145,11 +145,34 @@ export class TaskLogWriter { this.flushPendingText(); break; - case 'error': + case 'error': { this.flushPendingText(); - this.addEntry(logPhase, 'error', event.error.message); + // Extract meaningful error message - AI SDK sometimes just sends 'error' as the message + const err = event.error; + let errorContent = 'Unknown error'; + if (err) { + const msg = err.message || ''; + const code = err.code || ''; + const cause = err.cause; + // If message is just 'error' (unhelpful), try to extract from cause or code + if (msg && msg !== 'error') { + errorContent = msg; + } else if (cause instanceof Error) { + errorContent = cause.message || String(cause); + } else if (cause && typeof cause === 'object') { + errorContent = JSON.stringify(cause).slice(0, 500); + } else if (cause) { + errorContent = String(cause); + } else if (code && code !== 'error') { + errorContent = `Error code: ${code}`; + } else { + errorContent = `Error: ${JSON.stringify(err).slice(0, 500)}`; + } + } + this.addEntry(logPhase, 'error', errorContent); this.save(); break; + } default: // Ignore thinking-delta, usage-update diff --git a/apps/desktop/src/main/ai/mcp/registry.ts b/apps/desktop/src/main/ai/mcp/registry.ts index 54892f8d1d..362352f2b8 100644 --- a/apps/desktop/src/main/ai/mcp/registry.ts +++ b/apps/desktop/src/main/ai/mcp/registry.ts @@ -118,6 +118,55 @@ function createAutoClaudeServer(specDir: string): McpServerConfig { }; } +/** + * JIRA MCP server - issue tracking integration. + * Conditionally enabled when project has JIRA configured. + * Requires JIRA_HOST, JIRA_EMAIL, JIRA_TOKEN environment variables. + * + * Uses the community @modelcontextprotocol/server-atlassian package. + * If no suitable MCP server is available, JIRA access is handled + * directly via the IPC handlers (jira/issue-handlers.ts) instead. + */ +function createJiraServer(env: Record): McpServerConfig { + return { + id: 'jira', + name: 'JIRA', + description: 'Issue tracking integration for JIRA/Atlassian', + enabledByDefault: false, + transport: { + type: 'stdio', + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-atlassian'], + env: { + ATLASSIAN_SITE_URL: env.JIRA_HOST || '', + ATLASSIAN_USER_EMAIL: env.JIRA_EMAIL || '', + ATLASSIAN_API_TOKEN: env.JIRA_TOKEN || '', + }, + }, + }; +} + +/** + * Vault MCP server - external vault/Obsidian integration. + * Conditionally enabled when vault path is configured. + * Provides read-only file access to vault directory for agent context. + * + * Uses the official @modelcontextprotocol/server-filesystem package. + */ +function createVaultServer(vaultPath: string): McpServerConfig { + return { + id: 'vault', + name: 'Vault', + description: 'External vault integration for context and learnings', + enabledByDefault: false, + transport: { + type: 'stdio', + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-filesystem', vaultPath], + }, + }; +} + // ============================================================================= // Registry // ============================================================================= @@ -132,6 +181,10 @@ export interface McpRegistryOptions { linearApiKey?: string; /** Environment variables for server processes */ env?: Record; + /** JIRA environment variables (JIRA_HOST, JIRA_EMAIL, JIRA_TOKEN) */ + jiraEnv?: Record; + /** Vault path (if vault integration is enabled) */ + vaultPath?: string; } /** @@ -180,6 +233,23 @@ export function getMcpServerConfig( return createAutoClaudeServer(specDir); } + case 'jira': { + const jiraHost = options.jiraEnv?.JIRA_HOST ?? options.env?.JIRA_HOST; + if (!jiraHost) return null; + const jiraEnv = options.jiraEnv ?? { + JIRA_HOST: options.env?.JIRA_HOST ?? '', + JIRA_EMAIL: options.env?.JIRA_EMAIL ?? '', + JIRA_TOKEN: options.env?.JIRA_TOKEN ?? '', + }; + return createJiraServer(jiraEnv); + } + + case 'vault': { + const vPath = options.vaultPath ?? options.env?.VAULT_PATH; + if (!vPath) return null; + return createVaultServer(vPath); + } + default: return null; } diff --git a/apps/desktop/src/main/ai/mcp/types.ts b/apps/desktop/src/main/ai/mcp/types.ts index c0cefbd46b..f3d86340eb 100644 --- a/apps/desktop/src/main/ai/mcp/types.ts +++ b/apps/desktop/src/main/ai/mcp/types.ts @@ -49,7 +49,9 @@ export type McpServerId = | 'memory' | 'electron' | 'puppeteer' - | 'auto-claude'; + | 'auto-claude' + | 'jira' + | 'vault'; /** Configuration for a single MCP server */ export interface McpServerConfig { diff --git a/apps/desktop/src/main/ai/session/stream-handler.ts b/apps/desktop/src/main/ai/session/stream-handler.ts index 542bfb620d..09360f7c18 100644 --- a/apps/desktop/src/main/ai/session/stream-handler.ts +++ b/apps/desktop/src/main/ai/session/stream-handler.ts @@ -266,7 +266,33 @@ export function createStreamHandler(onEvent: SessionEventCallback) { } function handleError(part: ErrorPart): void { - const errorMessage = part.error instanceof Error ? part.error.message : String(part.error ?? 'Stream error'); + // DEBUG: Dump the raw error to console for diagnosis + console.warn('[StreamHandler] RAW ERROR:', JSON.stringify(part.error, Object.getOwnPropertyNames(part.error instanceof Error ? part.error : {}), 2).slice(0, 1000)); + console.warn('[StreamHandler] ERROR TYPE:', typeof part.error, part.error?.constructor?.name); + if (part.error instanceof Error) { + console.warn('[StreamHandler] ERROR CAUSE:', (part.error as { cause?: unknown }).cause); + console.warn('[StreamHandler] ERROR STACK:', part.error.stack?.slice(0, 500)); + } + + // Extract meaningful error message - AI SDK error objects may have nested cause + let errorMessage: string; + if (part.error instanceof Error) { + errorMessage = part.error.message; + // If message is just 'error', try cause + if (errorMessage === 'error' && (part.error as { cause?: unknown }).cause) { + const cause = (part.error as { cause?: unknown }).cause; + errorMessage = cause instanceof Error ? cause.message : String(cause); + } + // Also check stack for more context if message is unhelpful + if (errorMessage === 'error' && part.error.stack) { + errorMessage = part.error.stack.split('\n')[0] || 'Stream error'; + } + } else if (typeof part.error === 'object' && part.error !== null) { + const errObj = part.error as Record; + errorMessage = (errObj.message as string) || (errObj.text as string) || JSON.stringify(part.error).slice(0, 500); + } else { + errorMessage = String(part.error ?? 'Stream error'); + } const { sessionError } = classifyError(errorMessage); emit({ type: 'error', error: sessionError }); } diff --git a/apps/desktop/src/main/ipc-handlers/env-handlers.ts b/apps/desktop/src/main/ipc-handlers/env-handlers.ts index 7f7e5c3aeb..022bf50116 100644 --- a/apps/desktop/src/main/ipc-handlers/env-handlers.ts +++ b/apps/desktop/src/main/ipc-handlers/env-handlers.ts @@ -89,6 +89,29 @@ export function registerEnvHandlers( if (config.gitlabAutoSync !== undefined) { existingVars[GITLAB_ENV_KEYS.AUTO_SYNC] = config.gitlabAutoSync ? 'true' : 'false'; } + // Independent issue tracking flags (separate from source control) + if (config.githubIssuesEnabled !== undefined) { + existingVars['GITHUB_ISSUES_ENABLED'] = config.githubIssuesEnabled ? 'true' : 'false'; + } + if (config.gitlabIssuesEnabled !== undefined) { + existingVars['GITLAB_ISSUES_ENABLED'] = config.gitlabIssuesEnabled ? 'true' : 'false'; + } + // JIRA Integration + if (config.jiraEnabled !== undefined) { + existingVars['JIRA_ENABLED'] = config.jiraEnabled ? 'true' : 'false'; + } + if (config.jiraHost !== undefined) { + existingVars['JIRA_HOST'] = config.jiraHost; + } + if (config.jiraEmail !== undefined) { + existingVars['JIRA_EMAIL'] = config.jiraEmail; + } + if (config.jiraToken !== undefined) { + existingVars['JIRA_TOKEN'] = config.jiraToken; + } + if (config.jiraProjectKey !== undefined) { + existingVars['JIRA_PROJECT_KEY'] = config.jiraProjectKey; + } // Git/Worktree Settings if (config.defaultBranch !== undefined) { existingVars['DEFAULT_BRANCH'] = config.defaultBranch; @@ -212,6 +235,16 @@ ${envLine(existingVars, GITLAB_ENV_KEYS.INSTANCE_URL, 'https://gitlab.com')} ${envLine(existingVars, GITLAB_ENV_KEYS.TOKEN)} ${envLine(existingVars, GITLAB_ENV_KEYS.PROJECT, 'group/project')} ${envLine(existingVars, GITLAB_ENV_KEYS.AUTO_SYNC, 'false')} +${existingVars['GITLAB_ISSUES_ENABLED'] !== undefined ? `GITLAB_ISSUES_ENABLED=${existingVars['GITLAB_ISSUES_ENABLED']}` : '# GITLAB_ISSUES_ENABLED=false'} + +# ============================================================================= +# JIRA INTEGRATION (OPTIONAL) +# ============================================================================= +${existingVars['JIRA_ENABLED'] !== undefined ? `JIRA_ENABLED=${existingVars['JIRA_ENABLED']}` : '# JIRA_ENABLED=false'} +${envLine(existingVars, 'JIRA_HOST', 'https://your-domain.atlassian.net')} +${envLine(existingVars, 'JIRA_EMAIL')} +${envLine(existingVars, 'JIRA_TOKEN')} +${envLine(existingVars, 'JIRA_PROJECT_KEY', 'PROJ')} # ============================================================================= # GIT/WORKTREE SETTINGS (OPTIONAL) @@ -324,6 +357,7 @@ ${existingVars['GRAPHITI_DB_PATH'] ? `GRAPHITI_DB_PATH=${existingVars['GRAPHITI_ linearEnabled: false, githubEnabled: false, gitlabEnabled: false, + jiraEnabled: false, memoryEnabled: false, enableFancyUi: true, openaiKeyIsGlobal: false @@ -386,6 +420,30 @@ ${existingVars['GRAPHITI_DB_PATH'] ? `GRAPHITI_DB_PATH=${existingVars['GRAPHITI_ config.gitlabAutoSync = true; } + // Independent issue tracking flags + if (vars['GITHUB_ISSUES_ENABLED']?.toLowerCase() === 'true') { + config.githubIssuesEnabled = true; + } + if (vars['GITLAB_ISSUES_ENABLED']?.toLowerCase() === 'true') { + config.gitlabIssuesEnabled = true; + } + + // JIRA config + if (vars['JIRA_HOST']) { + config.jiraHost = vars['JIRA_HOST']; + // Enable by default if host exists and JIRA_ENABLED is not explicitly false + config.jiraEnabled = vars['JIRA_ENABLED']?.toLowerCase() !== 'false'; + } + if (vars['JIRA_EMAIL']) { + config.jiraEmail = vars['JIRA_EMAIL']; + } + if (vars['JIRA_TOKEN']) { + config.jiraToken = vars['JIRA_TOKEN']; + } + if (vars['JIRA_PROJECT_KEY']) { + config.jiraProjectKey = vars['JIRA_PROJECT_KEY']; + } + // Git/Worktree config if (vars['DEFAULT_BRANCH']) { config.defaultBranch = vars['DEFAULT_BRANCH']; diff --git a/apps/desktop/src/main/ipc-handlers/index.ts b/apps/desktop/src/main/ipc-handlers/index.ts index 98c06890c5..2ec0e0f714 100644 --- a/apps/desktop/src/main/ipc-handlers/index.ts +++ b/apps/desktop/src/main/ipc-handlers/index.ts @@ -34,6 +34,8 @@ import { registerProfileHandlers } from './profile-handlers'; import { registerScreenshotHandlers } from './screenshot-handlers'; import { registerTerminalWorktreeIpcHandlers } from './terminal'; import { registerCodexAuthHandlers } from './codex-auth-handlers'; +import { registerJiraHandlers } from './jira'; +import { registerVaultHandlers } from './vault'; import { notificationService } from '../notification-service'; import { setAgentManagerRef } from './utils'; @@ -127,6 +129,12 @@ export function setupIpcHandlers( // Codex OAuth authentication handlers registerCodexAuthHandlers(); + // JIRA integration handlers + registerJiraHandlers(agentManager, getMainWindow); + + // Vault integration handlers + registerVaultHandlers(); + console.warn('[IPC] All handler modules registered successfully'); } @@ -155,5 +163,7 @@ export { registerMcpHandlers, registerProfileHandlers, registerScreenshotHandlers, - registerCodexAuthHandlers + registerCodexAuthHandlers, + registerJiraHandlers, + registerVaultHandlers }; diff --git a/apps/desktop/src/main/ipc-handlers/jira/index.ts b/apps/desktop/src/main/ipc-handlers/jira/index.ts new file mode 100644 index 0000000000..e14370e5ca --- /dev/null +++ b/apps/desktop/src/main/ipc-handlers/jira/index.ts @@ -0,0 +1,27 @@ +/** + * JIRA IPC Handlers Module + * + * This module exports the main registration function for all JIRA-related IPC handlers. + */ + +import type { BrowserWindow } from 'electron'; +import type { AgentManager } from '../../agent'; + +import { registerJiraIssueHandlers } from './issue-handlers'; +import { registerJiraInvestigationHandlers } from './investigation-handlers'; + +/** + * Register all JIRA IPC handlers + */ +export function registerJiraHandlers( + agentManager: AgentManager, + getMainWindow: () => BrowserWindow | null +): void { + console.warn('[JIRA] Registering JIRA handlers'); + registerJiraIssueHandlers(); + registerJiraInvestigationHandlers(agentManager, getMainWindow); + console.warn('[JIRA] JIRA handlers registered'); +} + +// Re-export individual registration functions for custom usage +export { registerJiraIssueHandlers, registerJiraInvestigationHandlers }; diff --git a/apps/desktop/src/main/ipc-handlers/jira/investigation-handlers.ts b/apps/desktop/src/main/ipc-handlers/jira/investigation-handlers.ts new file mode 100644 index 0000000000..518743de1d --- /dev/null +++ b/apps/desktop/src/main/ipc-handlers/jira/investigation-handlers.ts @@ -0,0 +1,226 @@ +/** + * JIRA investigation handlers + * Handles AI-powered issue investigation + */ + +import { ipcMain, BrowserWindow } from 'electron'; +import { IPC_CHANNELS } from '../../../shared/constants'; +import type { JiraInvestigationStatus, JiraInvestigationResult } from '../../../shared/types'; +import { projectStore } from '../../project-store'; +import { getJiraConfig, jiraFetch } from './utils'; +import type { JiraAPIIssue } from './types'; +import { createSpecFromJiraIssue, fetchAllIssueComments } from './spec-utils'; +import type { JiraAPIComment } from './spec-utils'; +import type { AgentManager } from '../../agent'; + +// Debug logging helper +const DEBUG = process.env.DEBUG === 'true' || process.env.NODE_ENV === 'development'; + +function debugLog(message: string, data?: unknown): void { + if (DEBUG) { + if (data !== undefined) { + console.debug(`[JIRA Investigation] ${message}`, data); + } else { + console.debug(`[JIRA Investigation] ${message}`); + } + } +} + +/** + * Send investigation progress to renderer + */ +function sendProgress( + getMainWindow: () => BrowserWindow | null, + projectId: string, + status: JiraInvestigationStatus +): void { + const mainWindow = getMainWindow(); + if (mainWindow) { + mainWindow.webContents.send(IPC_CHANNELS.JIRA_INVESTIGATION_PROGRESS, projectId, status); + } +} + +/** + * Send investigation complete to renderer + */ +function sendComplete( + getMainWindow: () => BrowserWindow | null, + projectId: string, + result: JiraInvestigationResult +): void { + const mainWindow = getMainWindow(); + if (mainWindow) { + mainWindow.webContents.send(IPC_CHANNELS.JIRA_INVESTIGATION_COMPLETE, projectId, result); + } +} + +/** + * Send investigation error to renderer + */ +function sendError( + getMainWindow: () => BrowserWindow | null, + projectId: string, + error: string +): void { + const mainWindow = getMainWindow(); + if (mainWindow) { + mainWindow.webContents.send(IPC_CHANNELS.JIRA_INVESTIGATION_ERROR, projectId, error); + } +} + +/** + * Register the get issue comments handler + */ +function registerGetIssueComments(): void { + ipcMain.handle( + IPC_CHANNELS.JIRA_GET_ISSUE_COMMENTS, + async (_event, projectId: string, issueKey: string) => { + debugLog('getIssueComments handler called', { projectId, issueKey }); + + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + const config = await getJiraConfig(project); + if (!config) { + return { success: false, error: 'JIRA not configured' }; + } + + try { + const comments = await fetchAllIssueComments(config, issueKey); + return { success: true, data: comments }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Failed to fetch comments'; + debugLog('Failed to fetch comments:', errorMessage); + return { success: false, error: errorMessage }; + } + } + ); +} + +/** + * Register investigation handler + */ +export function registerInvestigateIssue( + _agentManager: AgentManager, + getMainWindow: () => BrowserWindow | null +): void { + ipcMain.on( + IPC_CHANNELS.JIRA_INVESTIGATE_ISSUE, + async (_event, projectId: string, issueKey: string, selectedCommentIds?: string[]) => { + debugLog('investigateJiraIssue handler called', { projectId, issueKey, selectedCommentIds }); + + const project = projectStore.getProject(projectId); + if (!project) { + sendError(getMainWindow, projectId, 'Project not found'); + return; + } + + const config = await getJiraConfig(project); + if (!config) { + sendError(getMainWindow, projectId, 'JIRA not configured'); + return; + } + + try { + // Phase 1: Fetching issue + sendProgress(getMainWindow, project.id, { + phase: 'fetching', + issueKey, + progress: 10, + message: 'Fetching issue details...' + }); + + // Fetch issue with all needed fields + const issue = await jiraFetch( + config, + `/issue/${encodeURIComponent(issueKey)}?fields=summary,description,status,assignee,priority,issuetype,created,updated,labels,project` + ) as JiraAPIIssue; + + // Fetch all comments (include by default, filter if specific IDs provided) + let filteredComments: JiraAPIComment[] = []; + try { + const allComments = await fetchAllIssueComments(config, issueKey); + if (selectedCommentIds && selectedCommentIds.length > 0) { + filteredComments = allComments.filter(comment => selectedCommentIds.includes(comment.id)); + } else { + // Include all comments by default + filteredComments = allComments; + } + } catch (commentErr) { + debugLog('Failed to fetch comments (non-fatal):', commentErr); + // Continue without comments + } + + // Phase 2: Creating task + sendProgress(getMainWindow, project.id, { + phase: 'creating_task', + issueKey, + progress: 50, + message: 'Creating task from issue...' + }); + + // Create spec for the issue with comments + const task = await createSpecFromJiraIssue( + project, + issueKey, + issue, + config, + project.settings?.mainBranch, + filteredComments + ); + + if (!task) { + sendError(getMainWindow, project.id, 'Failed to create task from issue'); + return; + } + + // Phase 3: Complete + sendProgress(getMainWindow, project.id, { + phase: 'complete', + issueKey, + progress: 100, + message: 'Investigation complete' + }); + + // Send result + const result: JiraInvestigationResult = { + success: true, + issueKey, + analysis: { + summary: `Investigation of JIRA issue ${issueKey}: ${issue.fields.summary}`, + proposedSolution: issue.fields.description + ? 'See task details for more information.' + : 'No description provided. See task details.', + affectedFiles: [], + estimatedComplexity: 'standard', + acceptanceCriteria: [] + }, + taskId: task.id + }; + + sendComplete(getMainWindow, project.id, result); + debugLog('Investigation complete:', { issueKey, 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 JIRA investigation handlers + */ +export function registerJiraInvestigationHandlers( + agentManager: AgentManager, + getMainWindow: () => BrowserWindow | null +): void { + debugLog('Registering JIRA investigation handlers'); + registerGetIssueComments(); + registerInvestigateIssue(agentManager, getMainWindow); + debugLog('JIRA investigation handlers registered'); +} diff --git a/apps/desktop/src/main/ipc-handlers/jira/issue-handlers.ts b/apps/desktop/src/main/ipc-handlers/jira/issue-handlers.ts new file mode 100644 index 0000000000..53682b0649 --- /dev/null +++ b/apps/desktop/src/main/ipc-handlers/jira/issue-handlers.ts @@ -0,0 +1,498 @@ +/** + * JIRA issue handlers + * Handles JIRA issue CRUD, transitions, and project listing + */ + +import { ipcMain } from 'electron'; +import type { IPCResult } from '../../../shared/types'; +import { projectStore } from '../../project-store'; +import { getJiraConfig, jiraFetch } from './utils'; +import { adfToPlainText } from './spec-utils'; +import type { + JiraIssue, + JiraProject, + JiraTransition, + JiraSearchResult, + JiraAPIIssue, + JiraAPISearchResponse, + JiraAPIProject, + JiraAPITransition, + JiraAPIUser +} from './types'; + +// Debug logging helper - enabled in development OR when DEBUG flag is set +const DEBUG = process.env.DEBUG === 'true' || process.env.NODE_ENV === 'development'; + +function debugLog(message: string, data?: unknown): void { + if (DEBUG) { + if (data !== undefined) { + console.debug(`[JIRA Issues] ${message}`, data); + } else { + console.debug(`[JIRA Issues] ${message}`); + } + } +} + +/** + * Transform JIRA API issue to our format + */ +function transformIssue(apiIssue: JiraAPIIssue): JiraIssue { + const descText = apiIssue.fields.description ? adfToPlainText(apiIssue.fields.description) : undefined; + return { + key: apiIssue.key, + summary: apiIssue.fields.summary, + description: descText || undefined, + status: apiIssue.fields.status.name, + assignee: apiIssue.fields.assignee?.displayName, + priority: apiIssue.fields.priority?.name, + issueType: apiIssue.fields.issuetype.name, + created: apiIssue.fields.created, + updated: apiIssue.fields.updated, + labels: apiIssue.fields.labels ?? [] + }; +} + +/** + * Transform JIRA API project to our format + */ +function transformProject(apiProject: JiraAPIProject): JiraProject { + return { + key: apiProject.key, + name: apiProject.name, + id: apiProject.id + }; +} + +/** + * Transform JIRA API transition to our format + */ +function transformTransition(apiTransition: JiraAPITransition): JiraTransition { + return { + id: apiTransition.id, + name: apiTransition.name, + to: { name: apiTransition.to.name } + }; +} + +/** + * Test JIRA connectivity - returns user display name + */ +export function registerTestConnection(): void { + ipcMain.handle( + 'jira:testConnection', + async (_event, projectId: string): Promise> => { + debugLog('testConnection handler called'); + + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + const config = await getJiraConfig(project); + if (!config) { + return { success: false, error: 'JIRA not configured' }; + } + + try { + const user = (await jiraFetch(config, '/myself')) as JiraAPIUser; + debugLog('Connection test successful:', user.displayName); + + return { + success: true, + data: { displayName: user.displayName } + }; + } catch (error) { + debugLog('Connection test failed:', error instanceof Error ? error.message : error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to connect to JIRA' + }; + } + } + ); +} + +/** + * List accessible JIRA projects + */ +export function registerListProjects(): void { + ipcMain.handle( + 'jira:listProjects', + async (_event, projectId: string): Promise> => { + debugLog('listProjects handler called'); + + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + const config = await getJiraConfig(project); + if (!config) { + return { success: false, error: 'JIRA not configured' }; + } + + try { + const apiProjects = (await jiraFetch(config, '/project')) as JiraAPIProject[]; + const projects = apiProjects.map(transformProject); + debugLog('Fetched projects:', projects.length); + + return { success: true, data: projects }; + } 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' + }; + } + } + ); +} + +/** + * Search JIRA issues using JQL + */ +export function registerSearchIssues(): void { + ipcMain.handle( + 'jira:searchIssues', + async ( + _event, + projectId: string, + jql: string, + maxResults?: number + ): Promise> => { + debugLog('searchIssues handler called', { jql, maxResults }); + + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + const config = await getJiraConfig(project); + if (!config) { + return { success: false, error: 'JIRA not configured' }; + } + + try { + const limit = maxResults ?? 50; + const apiResponse = (await jiraFetch( + config, + '/search/jql', + { + method: 'POST', + body: JSON.stringify({ + jql, + maxResults: limit, + fields: ['summary', 'description', 'status', 'assignee', 'priority', 'issuetype', 'created', 'updated', 'labels', 'project'] + }) + } + )) as JiraAPISearchResponse; + + const issues = apiResponse.issues.map(transformIssue); + debugLog('Search returned issues:', issues.length); + + return { + success: true, + data: { + issues, + total: apiResponse.total, + maxResults: apiResponse.maxResults + } + }; + } catch (error) { + debugLog('Failed to search issues:', error instanceof Error ? error.message : error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to search issues' + }; + } + } + ); +} + +/** + * Get a single JIRA issue by key + */ +export function registerGetIssue(): void { + ipcMain.handle( + 'jira:getIssue', + async (_event, projectId: string, issueKey: string): Promise> => { + debugLog('getIssue handler called', { issueKey }); + + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + const config = await getJiraConfig(project); + if (!config) { + return { success: false, error: 'JIRA not configured' }; + } + + try { + const apiIssue = (await jiraFetch( + config, + `/issue/${encodeURIComponent(issueKey)}?fields=summary,description,status,assignee,priority,issuetype,created,updated,labels,project` + )) as JiraAPIIssue; + + const issue = transformIssue(apiIssue); + 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' + }; + } + } + ); +} + +/** + * Create a new JIRA issue + */ +export function registerCreateIssue(): void { + ipcMain.handle( + 'jira:createIssue', + async ( + _event, + projectId: string, + fields: { + projectKey: string; + summary: string; + description?: string; + issueType: string; + priority?: string; + labels?: string[]; + assigneeAccountId?: string; + } + ): Promise> => { + debugLog('createIssue handler called', { projectKey: fields.projectKey, summary: fields.summary }); + + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + const config = await getJiraConfig(project); + if (!config) { + return { success: false, error: 'JIRA not configured' }; + } + + try { + const issueFields: Record = { + project: { key: fields.projectKey }, + summary: fields.summary, + issuetype: { name: fields.issueType } + }; + + if (fields.description) { + // JIRA API v3 uses Atlassian Document Format (ADF) + issueFields.description = { + type: 'doc', + version: 1, + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: fields.description }] + } + ] + }; + } + + if (fields.priority) { + issueFields.priority = { name: fields.priority }; + } + + if (fields.labels && fields.labels.length > 0) { + issueFields.labels = fields.labels; + } + + if (fields.assigneeAccountId) { + issueFields.assignee = { accountId: fields.assigneeAccountId }; + } + + const created = (await jiraFetch(config, '/issue', { + method: 'POST', + body: JSON.stringify({ fields: issueFields }) + })) as { id: string; key: string; self: string }; + + debugLog('Issue created:', created.key); + + // Fetch the full issue to return complete data + const apiIssue = (await jiraFetch( + config, + `/issue/${created.key}?fields=summary,description,status,assignee,priority,issuetype,created,updated,labels,project` + )) as JiraAPIIssue; + + const issue = transformIssue(apiIssue); + return { success: true, data: issue }; + } catch (error) { + debugLog('Failed to create issue:', error instanceof Error ? error.message : error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to create issue' + }; + } + } + ); +} + +/** + * Add a comment to a JIRA issue + */ +export function registerAddComment(): void { + ipcMain.handle( + 'jira:addComment', + async ( + _event, + projectId: string, + issueKey: string, + body: string + ): Promise> => { + debugLog('addComment handler called', { issueKey }); + + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + const config = await getJiraConfig(project); + if (!config) { + return { success: false, error: 'JIRA not configured' }; + } + + try { + // JIRA API v3 uses Atlassian Document Format (ADF) for comments + const comment = (await jiraFetch(config, `/issue/${encodeURIComponent(issueKey)}/comment`, { + method: 'POST', + body: JSON.stringify({ + body: { + type: 'doc', + version: 1, + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: body }] + } + ] + } + }) + })) as { id: string }; + + debugLog('Comment added:', comment.id); + return { success: true, data: { id: comment.id } }; + } catch (error) { + debugLog('Failed to add comment:', error instanceof Error ? error.message : error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to add comment' + }; + } + } + ); +} + +/** + * Get available transitions for a JIRA issue + */ +export function registerGetTransitions(): void { + ipcMain.handle( + 'jira:getTransitions', + async ( + _event, + projectId: string, + issueKey: string + ): Promise> => { + debugLog('getTransitions handler called', { issueKey }); + + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + const config = await getJiraConfig(project); + if (!config) { + return { success: false, error: 'JIRA not configured' }; + } + + try { + const response = (await jiraFetch( + config, + `/issue/${encodeURIComponent(issueKey)}/transitions` + )) as { transitions: JiraAPITransition[] }; + + const transitions = response.transitions.map(transformTransition); + debugLog('Fetched transitions:', transitions.length); + + return { success: true, data: transitions }; + } catch (error) { + debugLog('Failed to get transitions:', error instanceof Error ? error.message : error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to get transitions' + }; + } + } + ); +} + +/** + * Transition a JIRA issue to a new status + */ +export function registerTransitionIssue(): void { + ipcMain.handle( + 'jira:transitionIssue', + async ( + _event, + projectId: string, + issueKey: string, + transitionId: string + ): Promise> => { + debugLog('transitionIssue handler called', { issueKey, transitionId }); + + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + const config = await getJiraConfig(project); + if (!config) { + return { success: false, error: 'JIRA not configured' }; + } + + try { + await jiraFetch(config, `/issue/${encodeURIComponent(issueKey)}/transitions`, { + method: 'POST', + body: JSON.stringify({ + transition: { id: transitionId } + }) + }); + + debugLog('Issue transitioned successfully:', issueKey); + return { success: true }; + } catch (error) { + debugLog('Failed to transition issue:', error instanceof Error ? error.message : error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to transition issue' + }; + } + } + ); +} + +/** + * Register all JIRA issue handlers + */ +export function registerJiraIssueHandlers(): void { + debugLog('Registering JIRA issue handlers'); + registerTestConnection(); + registerListProjects(); + registerSearchIssues(); + registerGetIssue(); + registerCreateIssue(); + registerAddComment(); + registerGetTransitions(); + registerTransitionIssue(); + debugLog('JIRA issue handlers registered'); +} diff --git a/apps/desktop/src/main/ipc-handlers/jira/spec-utils.ts b/apps/desktop/src/main/ipc-handlers/jira/spec-utils.ts new file mode 100644 index 0000000000..9adf84ff2c --- /dev/null +++ b/apps/desktop/src/main/ipc-handlers/jira/spec-utils.ts @@ -0,0 +1,536 @@ +/** + * JIRA spec utilities + * Handles creating task specs from JIRA issues + */ + +import { mkdir, writeFile, readFile, stat } from 'fs/promises'; +import path from 'path'; +import type { Project } from '../../../shared/types'; +import type { JiraAPIIssue, JiraConfig } from './types'; +import { labelMatchesWholeWord } from '../shared/label-utils'; +import { sanitizeText, sanitizeStringArray } from '../shared/sanitize'; + +/** + * Simplified task info returned when creating a spec from a JIRA issue. + * This is not a full Task object - it's just the basic info needed for the UI. + */ +export interface JiraTaskInfo { + id: string; + specId: string; + title: string; + description: string; + createdAt: Date; + updatedAt: Date; +} + +/** + * JIRA comment structure (from REST API v3) + */ +export interface JiraAPIComment { + id: string; + body: unknown; // ADF format or string + author: { + accountId: string; + displayName: string; + }; + created: string; + updated: string; +} + +/** + * JIRA comments pagination response + */ +export interface JiraAPICommentsResponse { + comments: JiraAPIComment[]; + total: number; + maxResults: number; + startAt: number; +} + +// Debug logging helper +const DEBUG = process.env.DEBUG === 'true' || process.env.NODE_ENV === 'development'; + +function debugLog(message: string, data?: unknown): void { + if (DEBUG) { + if (data !== undefined) { + console.debug(`[JIRA Spec] ${message}`, data); + } else { + console.debug(`[JIRA Spec] ${message}`); + } + } +} + +/** + * Determine task category based on JIRA issue labels and type + * Maps to TaskCategory type from shared/types/task.ts + */ +function determineCategoryFromLabelsAndType( + labels: string[], + issueType: string +): 'feature' | 'bug_fix' | 'refactoring' | 'documentation' | 'security' | 'performance' | 'ui_ux' | 'infrastructure' | 'testing' { + const lowerLabels = labels.map(l => l.toLowerCase()); + const lowerType = issueType.toLowerCase(); + + // Check issue type first + if (lowerType.includes('bug') || lowerType.includes('defect')) { + return 'bug_fix'; + } + + if (lowerLabels.some(l => l.includes('bug') || l.includes('defect') || l.includes('error') || l.includes('fix'))) { + return 'bug_fix'; + } + if (lowerLabels.some(l => l.includes('security') || l.includes('vulnerability') || l.includes('cve'))) { + return 'security'; + } + if (lowerLabels.some(l => l.includes('performance') || l.includes('optimization') || l.includes('speed'))) { + return 'performance'; + } + if (lowerLabels.some(l => l.includes('ui') || l.includes('ux') || l.includes('design') || l.includes('styling'))) { + return 'ui_ux'; + } + // Use whole-word matching for 'ci' and 'cd' to avoid false positives like 'acid' or 'decide' + if (lowerLabels.some(l => + l.includes('infrastructure') || + l.includes('devops') || + l.includes('deployment') || + labelMatchesWholeWord(l, 'ci') || + labelMatchesWholeWord(l, 'cd') + )) { + return 'infrastructure'; + } + if (lowerLabels.some(l => l.includes('test') || l.includes('testing') || l.includes('qa'))) { + return 'testing'; + } + if (lowerLabels.some(l => l.includes('refactor') || l.includes('cleanup') || l.includes('maintenance') || l.includes('chore') || l.includes('tech-debt') || l.includes('technical debt'))) { + return 'refactoring'; + } + if (lowerLabels.some(l => l.includes('documentation') || l.includes('docs'))) { + return 'documentation'; + } + return 'feature'; +} + +/** + * Convert ADF (Atlassian Document Format) to plain text. + * JIRA API v3 returns descriptions and comments in ADF format. + */ +export function adfToPlainText(adf: unknown): string { + if (typeof adf === 'string') return adf; + if (!adf || typeof adf !== 'object') return ''; + + const doc = adf as { content?: unknown[] }; + if (!Array.isArray(doc.content)) return ''; + + const lines: string[] = []; + + function extractText(node: unknown): void { + if (!node || typeof node !== 'object') return; + const n = node as { type?: string; text?: string; content?: unknown[] }; + + if (n.type === 'text' && typeof n.text === 'string') { + lines.push(n.text); + return; + } + + if (n.type === 'hardBreak') { + lines.push('\n'); + return; + } + + if (Array.isArray(n.content)) { + for (const child of n.content) { + extractText(child); + } + // Add newline after block-level elements + if (n.type === 'paragraph' || n.type === 'heading' || n.type === 'bulletList' || n.type === 'orderedList' || n.type === 'listItem' || n.type === 'codeBlock' || n.type === 'blockquote') { + lines.push('\n'); + } + } + } + + for (const block of doc.content) { + extractText(block); + } + + return lines.join('').trim(); +} + +/** + * Sanitize a JIRA issue key (e.g., PROJ-123) + */ +function sanitizeIssueKey(value: unknown): string { + if (typeof value !== 'string') return ''; + // JIRA keys follow pattern: PROJECT-NUMBER + const match = value.match(/^[A-Z][A-Z0-9_]+-\d+$/); + return match ? value : ''; +} + +/** + * Sanitize a JIRA host URL for spec metadata + */ +function sanitizeHostUrl(value: unknown): string { + if (typeof value !== 'string') return ''; + try { + const parsed = new URL(value); + if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') return ''; + if (parsed.username || parsed.password) return ''; + return parsed.origin; + } catch { + return ''; + } +} + +/** + * Generate a spec directory name from issue key and title + */ +function generateSpecDirName(issueKey: string, 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: PROJ-123-issue-title (using issue key) + const safeName = issueKey.toLowerCase().replace(/[^a-z0-9-]/g, '-'); + return `${safeName}-${cleanTitle}`; +} + +/** + * Check if a path exists (async) + */ +async function pathExists(filePath: string): Promise { + try { + await stat(filePath); + return true; + } catch { + return false; + } +} + +/** + * Build issue context for spec creation + */ +export function buildIssueContext( + issueKey: string, + summary: string, + description: string, + issueType: string, + status: string, + priority: string | undefined, + labels: string[], + assignee: string | undefined, + created: string, + jiraUrl: string, + comments?: JiraAPIComment[] +): string { + const lines: string[] = []; + + const safeKey = sanitizeText(issueKey, 50); + const safeSummary = sanitizeText(summary, 200); + + lines.push(`# JIRA Issue ${safeKey}: ${safeSummary}`); + lines.push(''); + lines.push(`**Issue Type:** ${sanitizeText(issueType, 100)}`); + lines.push(`**Status:** ${sanitizeText(status, 100)}`); + lines.push(`**Created:** ${new Date(created).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })}`); + + if (priority) { + lines.push(`**Priority:** ${sanitizeText(priority, 100)}`); + } + + if (labels.length > 0) { + const safeLabels = sanitizeStringArray(labels, 50, 100); + lines.push(`**Labels:** ${safeLabels.join(', ')}`); + } + + if (assignee) { + lines.push(`**Assignee:** ${sanitizeText(assignee, 100)}`); + } + + lines.push(''); + lines.push('## Description'); + lines.push(''); + lines.push(sanitizeText(description, 20000, true) || '_No description provided_'); + lines.push(''); + lines.push(`**JIRA URL:** ${jiraUrl}`); + + // Add comments section if comments are provided + if (comments && comments.length > 0) { + lines.push(''); + lines.push(`## Comments (${comments.length})`); + lines.push(''); + for (const comment of comments) { + const safeAuthor = sanitizeText(comment.author?.displayName || 'unknown', 100); + const safeBody = sanitizeText(adfToPlainText(comment.body), 20000, true); + lines.push(`**${safeAuthor}:** ${safeBody}`); + lines.push(''); + } + } + + return lines.join('\n'); +} + +/** + * Fetches all comments for a JIRA issue with pagination. + * Handles rate limiting and authentication errors gracefully. + */ +export async function fetchAllIssueComments( + config: JiraConfig, + issueKey: string +): Promise { + const { jiraFetch, JiraAPIError } = await import('./utils'); + + const allComments: JiraAPIComment[] = []; + let startAt = 0; + const maxResults = 50; + const MAX_PAGES = 20; // Safety limit: max 1000 comments + let hasMore = true; + let page = 0; + + while (hasMore && page < MAX_PAGES) { + try { + const response = await jiraFetch( + config, + `/issue/${encodeURIComponent(issueKey)}/comment?startAt=${startAt}&maxResults=${maxResults}` + ) as JiraAPICommentsResponse; + + // Runtime validation: ensure we got the expected shape + if (!response || !Array.isArray(response.comments)) { + debugLog('JIRA comments API returned unexpected shape, stopping pagination'); + break; + } + + if (response.comments.length === 0) { + hasMore = false; + } else { + // Extract only needed fields with null-safe defaults + const commentSummaries: JiraAPIComment[] = response.comments + .filter((comment: unknown): comment is Record => + comment !== null && typeof comment === 'object' && typeof (comment as Record).id === 'string' + ) + .map((comment) => { + const c = comment as unknown as Record; + const author = c.author; + const displayName = (author !== null && typeof author === 'object' && typeof (author as Record).displayName === 'string') + ? (author as Record).displayName as string + : 'unknown'; + const accountId = (author !== null && typeof author === 'object' && typeof (author as Record).accountId === 'string') + ? (author as Record).accountId as string + : ''; + return { + id: c.id as string, + body: c.body ?? '', + author: { displayName, accountId }, + created: (c.created as string) || new Date().toISOString(), + updated: (c.updated as string) || new Date().toISOString(), + }; + }); + allComments.push(...commentSummaries); + + if (startAt + response.comments.length >= response.total) { + hasMore = false; + } else { + startAt += response.comments.length; + page++; + } + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + + // Check for authentication/rate-limit errors using structured status codes + const isAuthError = error instanceof JiraAPIError && (error.statusCode === 401 || error.statusCode === 403); + const isRateLimited = error instanceof JiraAPIError && error.statusCode === 429; + + if (isAuthError || isRateLimited) { + // Re-throw critical errors to let the caller surface them to the user + const statusCode = error instanceof JiraAPIError ? error.statusCode : undefined; + console.warn(`[JIRA Comments] ${isAuthError ? 'Authentication' : 'Rate limit'} error during comments fetch`, { page, error: errorMessage, statusCode }); + throw error; + } + + // For transient errors on page 1, warn the user but continue + if (page === 0 && allComments.length === 0) { + console.warn('[JIRA Comments] Failed to fetch any comments, proceeding without comments context', { error: errorMessage }); + } else { + // Log pagination failure for subsequent pages + debugLog('Failed to fetch comments page, using partial comments', { page, error: errorMessage, commentsRetrieved: allComments.length }); + } + hasMore = false; + } + } + + // Warn if we hit the pagination limit + if (page >= MAX_PAGES && hasMore) { + debugLog('Pagination limit reached, some comments may be missing', { maxPages: MAX_PAGES, commentsRetrieved: allComments.length }); + } + + return allComments; +} + +/** + * Create a task spec from a JIRA issue + */ +export async function createSpecFromJiraIssue( + project: Project, + issueKey: string, + issueData: JiraAPIIssue, + config: JiraConfig, + baseBranch?: string, + selectedComments?: JiraAPIComment[] +): Promise { + try { + const safeKey = sanitizeIssueKey(issueKey); + if (!safeKey) { + debugLog('Skipping issue with invalid key', { key: issueKey }); + return null; + } + + const safeSummary = sanitizeText(issueData.fields.summary, 200) || `Issue ${safeKey}`; + const safeDescription = adfToPlainText(issueData.fields.description); + const safeHost = sanitizeHostUrl(config.host); + const safeLabels = sanitizeStringArray(issueData.fields.labels, 50, 100); + + const specsDir = path.join(project.path, project.autoBuildPath, 'specs'); + + // Ensure specs directory exists + await mkdir(specsDir, { recursive: true }); + + // Generate spec directory name + const specDirName = generateSpecDirName(safeKey, safeSummary); + const specDir = path.join(specsDir, specDirName); + const metadataPath = path.join(specDir, 'metadata.json'); + + // Check if spec already exists + if (await pathExists(specDir)) { + debugLog('Spec already exists for issue:', { key: safeKey, specDir }); + + // Read existing metadata for accurate timestamps + let createdAt = new Date(issueData.fields.created); + let updatedAt = createdAt; + + if (await pathExists(metadataPath)) { + try { + const metadataContent = await readFile(metadataPath, 'utf-8'); + const metadata = JSON.parse(metadataContent); + if (metadata.createdAt) { + createdAt = new Date(metadata.createdAt); + } + // Use file modification time for updatedAt + const stats = await stat(metadataPath); + updatedAt = new Date(stats.mtimeMs); + } catch { + // Fallback to issue dates if metadata read fails + } + } + + // Return existing task info + return { + id: specDirName, + specId: specDirName, + title: safeSummary, + description: safeDescription, + createdAt, + updatedAt + }; + } + + // Create spec directory + await mkdir(specDir, { recursive: true }); + + // Build JIRA URL for the issue + const jiraUrl = `${safeHost}/browse/${safeKey}`; + + // Create TASK.md with issue context (including selected comments) + const taskContent = buildIssueContext( + safeKey, + safeSummary, + safeDescription, + issueData.fields.issuetype.name, + issueData.fields.status.name, + issueData.fields.priority?.name, + safeLabels, + issueData.fields.assignee?.displayName, + issueData.fields.created, + jiraUrl, + selectedComments + ); + await writeFile(path.join(specDir, 'TASK.md'), taskContent, 'utf-8'); + + // Create metadata.json (JIRA-specific data) + const metadata = { + source: 'jira', + jira: { + issueId: issueData.id, + issueKey: safeKey, + host: safeHost, + projectKey: issueData.fields.project.key, + webUrl: jiraUrl, + status: issueData.fields.status.name, + issueType: issueData.fields.issuetype.name, + labels: safeLabels, + createdAt: issueData.fields.created + }, + createdAt: new Date().toISOString(), + status: 'pending' + }; + await writeFile(metadataPath, JSON.stringify(metadata, null, 2), 'utf-8'); + + // Create task_metadata.json (consistent format for backend compatibility) + const taskMetadata = { + sourceType: 'jira' as const, + jiraIssueKey: safeKey, + jiraUrl, + category: determineCategoryFromLabelsAndType(safeLabels, issueData.fields.issuetype.name), + // Store baseBranch for worktree creation and QA comparison + ...(baseBranch && { baseBranch }) + }; + await writeFile( + path.join(specDir, 'task_metadata.json'), + JSON.stringify(taskMetadata, null, 2), + 'utf-8' + ); + + // Create requirements.json (needed for task description in Kanban board) + const requirements = { + task_description: safeDescription || safeSummary, + title: safeSummary, + source: 'jira', + jiraIssueKey: safeKey, + jiraUrl + }; + await writeFile( + path.join(specDir, 'requirements.json'), + JSON.stringify(requirements, null, 2), + 'utf-8' + ); + + // Create implementation_plan.json (empty plan, pending status) + const implementationPlan = { + title: safeSummary, + description: safeDescription || safeSummary, + status: 'pending', + phases: [] + }; + await writeFile( + path.join(specDir, 'implementation_plan.json'), + JSON.stringify(implementationPlan, null, 2), + 'utf-8' + ); + + debugLog('Created spec for issue:', { key: safeKey, specDir }); + + // Return task info + return { + id: specDirName, + specId: specDirName, + title: safeSummary, + description: safeDescription, + createdAt: new Date(issueData.fields.created), + updatedAt: new Date() + }; + } catch (error) { + debugLog('Failed to create spec for issue:', { key: issueKey, error }); + return null; + } +} diff --git a/apps/desktop/src/main/ipc-handlers/jira/types.ts b/apps/desktop/src/main/ipc-handlers/jira/types.ts new file mode 100644 index 0000000000..0e427e9f6d --- /dev/null +++ b/apps/desktop/src/main/ipc-handlers/jira/types.ts @@ -0,0 +1,92 @@ +/** + * JIRA module types and interfaces + */ + +export interface JiraConfig { + host: string; // e.g., https://company.atlassian.net + email: string; // JIRA user email + token: string; // API token + projectKey?: string; // Default project key +} + +export interface JiraIssue { + key: string; + summary: string; + description?: string; + status: string; + assignee?: string; + priority?: string; + issueType: string; + created: string; + updated: string; + labels: string[]; +} + +export interface JiraSearchResult { + issues: JiraIssue[]; + total: number; + maxResults: number; +} + +export interface JiraTransition { + id: string; + name: string; + to: { name: string }; +} + +export interface JiraProject { + key: string; + name: string; + id: string; +} + +/** + * JIRA REST API response types (raw API shapes) + */ + +export interface JiraAPIUser { + accountId: string; + displayName: string; + emailAddress?: string; + avatarUrls?: Record; + active: boolean; +} + +export interface JiraAPIIssueFields { + summary: string; + description?: string; + status: { name: string; id: string }; + assignee?: JiraAPIUser; + priority?: { name: string; id: string }; + issuetype: { name: string; id: string }; + created: string; + updated: string; + labels: string[]; + project: { key: string; name: string; id: string }; +} + +export interface JiraAPIIssue { + id: string; + key: string; + fields: JiraAPIIssueFields; +} + +export interface JiraAPISearchResponse { + issues: JiraAPIIssue[]; + total: number; + maxResults: number; + startAt: number; +} + +export interface JiraAPIProject { + id: string; + key: string; + name: string; + projectTypeKey: string; +} + +export interface JiraAPITransition { + id: string; + name: string; + to: { name: string; id: string }; +} diff --git a/apps/desktop/src/main/ipc-handlers/jira/utils.ts b/apps/desktop/src/main/ipc-handlers/jira/utils.ts new file mode 100644 index 0000000000..b24bdb0501 --- /dev/null +++ b/apps/desktop/src/main/ipc-handlers/jira/utils.ts @@ -0,0 +1,252 @@ +/** + * JIRA utility functions + */ + +import { readFile, access } from 'fs/promises'; +import path from 'path'; +import type { Project } from '../../../shared/types'; +import { parseEnvFile } from '../utils'; +import type { JiraConfig } from './types'; + +// Default timeout for JIRA API requests (30 seconds) +const JIRA_API_TIMEOUT_MS = 30000; + +/** + * Custom error class for JIRA API errors with structured status code + */ +export class JiraAPIError extends Error { + public readonly statusCode: number; + + constructor(message: string, statusCode: number) { + super(message); + this.name = 'JiraAPIError'; + this.statusCode = statusCode; + } +} + +// JIRA environment variable keys +const JIRA_ENV_KEYS = { + ENABLED: 'JIRA_ENABLED', + HOST: 'JIRA_HOST', + EMAIL: 'JIRA_EMAIL', + TOKEN: 'JIRA_TOKEN', + PROJECT_KEY: 'JIRA_PROJECT_KEY' +} as const; + +/** + * Check if a file exists (async) + */ +async function fileExists(filePath: string): Promise { + try { + await access(filePath); + return true; + } catch { + return false; + } +} + +/** + * Private IP ranges to block for SSRF prevention + */ +const PRIVATE_IP_PATTERNS = [ + /^127\./, // 127.0.0.0/8 (loopback) + /^10\./, // 10.0.0.0/8 + /^172\.(1[6-9]|2[0-9]|3[0-1])\./, // 172.16.0.0/12 + /^192\.168\./, // 192.168.0.0/16 + /^169\.254\./, // 169.254.0.0/16 (link-local, includes cloud metadata) + /^0\./, // 0.0.0.0/8 + /^::1$/, // IPv6 loopback + /^fc00:/i, // IPv6 unique local + /^fe80:/i // IPv6 link-local +]; + +/** + * Check if a hostname resolves to a private/internal IP + */ +function isPrivateHost(hostname: string): boolean { + // Block known private hostnames + if (hostname === 'localhost' || hostname === 'metadata.google.internal') { + return true; + } + + // Check if the hostname itself is an IP address matching private ranges + for (const pattern of PRIVATE_IP_PATTERNS) { + if (pattern.test(hostname)) { + return true; + } + } + + return false; +} + +/** + * Sanitize and validate a JIRA host URL + * + * Validates: + * - Must be a valid URL + * - Protocol must be https (allow http only for localhost in development) + * - No credentials in URL + * - Block private/internal IPs (SSRF prevention) + * - Block cloud metadata endpoints + */ +export function sanitizeJiraHost(value: string): string | null { + const candidate = value.trim(); + if (!candidate) return null; + + try { + const parsed = new URL(candidate); + + // Must be https (allow http only for localhost in development) + if (parsed.protocol !== 'https:') { + if (parsed.protocol === 'http:' && parsed.hostname === 'localhost' && process.env.NODE_ENV === 'development') { + // Allow http://localhost in development only + } else { + return null; + } + } + + // No credentials in URL + if (parsed.username || parsed.password) { + return null; + } + + // Must have a hostname + if (!parsed.hostname) { + return null; + } + + // Block private/internal IPs + if (isPrivateHost(parsed.hostname)) { + return null; + } + + // Return origin (protocol + host, no trailing path) + return parsed.origin; + } catch { + return null; + } +} + +/** + * Sanitize a token value - strip control characters and limit length + */ +function sanitizeToken(value: string | undefined): string | null { + if (!value) return null; + let sanitized = ''; + for (let i = 0; i < value.length; i += 1) { + const code = value.charCodeAt(i); + if (code <= 0x1f || code === 0x7f) { + continue; + } + sanitized += value[i]; + } + const trimmed = sanitized.trim(); + if (!trimmed) return null; + return trimmed.length > 512 ? trimmed.substring(0, 512) : trimmed; +} + +/** + * Sanitize an email value - basic validation + */ +function sanitizeEmail(value: string | undefined): string | null { + if (!value) return null; + const trimmed = value.trim(); + if (!trimmed) return null; + // Basic email format check + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)) return null; + return trimmed.length > 254 ? null : trimmed; +} + +/** + * Get JIRA configuration from project environment file + * Returns null if JIRA is explicitly disabled or not configured + */ +export async function getJiraConfig(project: Project): Promise { + if (!project.autoBuildPath) return null; + const envPath = path.join(project.path, project.autoBuildPath, '.env'); + if (!(await fileExists(envPath))) return null; + + try { + const content = await readFile(envPath, 'utf-8'); + const vars = parseEnvFile(content); + + // Check if JIRA is explicitly disabled + if (vars[JIRA_ENV_KEYS.ENABLED]?.toLowerCase() === 'false') { + return null; + } + + const host = sanitizeJiraHost(vars[JIRA_ENV_KEYS.HOST] ?? ''); + const email = sanitizeEmail(vars[JIRA_ENV_KEYS.EMAIL]); + const token = sanitizeToken(vars[JIRA_ENV_KEYS.TOKEN]); + const projectKey = vars[JIRA_ENV_KEYS.PROJECT_KEY]?.trim() || undefined; + + if (!host || !email || !token) return null; + + return { host, email, token, projectKey }; + } catch { + return null; + } +} + +/** + * Make a request to the JIRA REST API with timeout + * + * Uses Basic authentication with email:token encoded as base64 + */ +export async function jiraFetch( + config: JiraConfig, + endpoint: string, + options: RequestInit = {} +): Promise { + const host = sanitizeJiraHost(config.host); + if (!host) { + throw new JiraAPIError('Invalid JIRA host URL', 0); + } + if (!endpoint.startsWith('/')) { + throw new JiraAPIError('JIRA endpoint must be a relative path', 0); + } + + const url = `${host}/rest/api/3${endpoint}`; + + // Basic auth: base64(email:token) + const credentials = Buffer.from(`${config.email}:${config.token}`).toString('base64'); + + // Create abort controller for timeout + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), JIRA_API_TIMEOUT_MS); + + try { + const response = await fetch(url, { + ...options, + signal: controller.signal, + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + ...options.headers, + Authorization: `Basic ${credentials}` + } + }); + + if (!response.ok) { + const errorBody = await response.text(); + throw new JiraAPIError( + `JIRA API error: ${response.status} ${response.statusText} - ${errorBody}`, + response.status + ); + } + + // Some JIRA endpoints return 204 No Content + if (response.status === 204) { + return null; + } + + return response.json(); + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + throw new JiraAPIError(`JIRA API timeout after ${JIRA_API_TIMEOUT_MS / 1000}s: ${url}`, 0); + } + throw error; + } finally { + clearTimeout(timeoutId); + } +} diff --git a/apps/desktop/src/main/ipc-handlers/vault/index.ts b/apps/desktop/src/main/ipc-handlers/vault/index.ts new file mode 100644 index 0000000000..4fa578f165 --- /dev/null +++ b/apps/desktop/src/main/ipc-handlers/vault/index.ts @@ -0,0 +1,22 @@ +/** + * Vault IPC Handlers Module + * + * This module exports the main registration function for all vault-related IPC handlers. + * A vault is an external directory (like an Obsidian vault) that contains markdown files + * with learnings, context, and agent instructions. + */ + +import { registerVaultIpcHandlers } from './vault-handlers'; + +/** + * Register all vault IPC handlers + */ +export function registerVaultHandlers(): void { + console.warn('[Vault] Registering vault handlers'); + registerVaultIpcHandlers(); + console.warn('[Vault] Vault handlers registered'); +} + +// Re-export types and utilities for external use +export type { VaultConfig, VaultFile, VaultSearchResult, VaultContext, VaultLearning } from './types'; +export { getVaultConfig, isValidVaultPath, readVaultClaudeMd, listVaultLearnings } from './utils'; diff --git a/apps/desktop/src/main/ipc-handlers/vault/types.ts b/apps/desktop/src/main/ipc-handlers/vault/types.ts new file mode 100644 index 0000000000..bd190e4c57 --- /dev/null +++ b/apps/desktop/src/main/ipc-handlers/vault/types.ts @@ -0,0 +1,39 @@ +/** + * Vault module types and interfaces + * + * A vault is an external directory (like an Obsidian vault) containing + * markdown files with learnings, context, and agent instructions. + */ + +export interface VaultConfig { + path: string; // Absolute path to vault directory + enabled: boolean; // Whether vault integration is active + syncLearnings: boolean; // Whether to sync learnings to vault + autoLoad: boolean; // Whether to auto-load vault context + writeEnabled: boolean; // Whether write operations are allowed +} + +export interface VaultFile { + name: string; + path: string; // Relative path within vault + size: number; + modified: string; // ISO date + isDirectory: boolean; +} + +export interface VaultSearchResult { + files: VaultFile[]; + total: number; + query: string; +} + +export interface VaultContext { + claudeMd: string | null; + learnings: VaultLearning[]; +} + +export interface VaultLearning { + name: string; + path: string; // Relative path within vault + content: string; +} diff --git a/apps/desktop/src/main/ipc-handlers/vault/utils.ts b/apps/desktop/src/main/ipc-handlers/vault/utils.ts new file mode 100644 index 0000000000..e8069b5fdf --- /dev/null +++ b/apps/desktop/src/main/ipc-handlers/vault/utils.ts @@ -0,0 +1,328 @@ +/** + * Vault utility functions + * + * Provides path validation, context reading, and security checks + * for external vault directories. + */ + +import { readFile, readdir, stat, realpath, access } from 'fs/promises'; +import path from 'path'; +import type { VaultConfig, VaultFile, VaultLearning } from './types'; + +/** Maximum characters to read from CLAUDE.md */ +const MAX_CLAUDE_MD_CHARS = 10000; + +/** Maximum number of learning files to load */ +const MAX_LEARNING_FILES = 5; + +/** Maximum characters per learning file */ +const MAX_LEARNING_CHARS = 2000; + +/** Maximum allowed path length */ +const MAX_PATH_LENGTH = 1024; + +/** Maximum file size for reads (50KB) */ +export const MAX_FILE_SIZE = 50000; + +/** + * Sensitive system directories that must never be used as vault paths. + * Checked against the resolved real path to prevent symlink bypasses. + */ +const SENSITIVE_DIRECTORIES: readonly string[] = [ + '/etc', + '/var', + '/usr', + '/bin', + '/sbin', + '/lib', + '/proc', + '/sys', + '/dev', + '/boot', + '/tmp', + '/private/etc', + '/private/var', + // Windows + '/Windows', + '/Program Files', + '/Program Files (x86)', +]; + +/** + * Extract vault config from app settings object. + * Returns null if vault is not configured or disabled. + */ +export function getVaultConfig(settings: Record): VaultConfig | null { + const vault = settings.vault as Record | undefined; + if (!vault) return null; + + const vaultPath = typeof vault.path === 'string' ? vault.path : ''; + const enabled = typeof vault.enabled === 'boolean' ? vault.enabled : false; + + if (!enabled || !vaultPath) return null; + + return { + path: vaultPath, + enabled, + syncLearnings: typeof vault.syncLearnings === 'boolean' ? vault.syncLearnings : false, + autoLoad: typeof vault.autoLoad === 'boolean' ? vault.autoLoad : true, + writeEnabled: typeof vault.writeEnabled === 'boolean' ? vault.writeEnabled : false, + }; +} + +/** + * Validate that a path is safe to use as a vault directory. + * + * Checks: + * - Path is absolute + * - Path length is within limits + * - Directory exists and is accessible + * - Not a sensitive system directory + * - Symlinks do not escape to sensitive directories + */ +export async function isValidVaultPath(vaultPath: string): Promise<{ valid: boolean; error?: string }> { + // Check path length + if (!vaultPath || vaultPath.length > MAX_PATH_LENGTH) { + return { valid: false, error: 'Vault path is empty or exceeds maximum length' }; + } + + // Must be absolute + if (!path.isAbsolute(vaultPath)) { + return { valid: false, error: 'Vault path must be an absolute path' }; + } + + // Check existence and type + try { + await access(vaultPath); + } catch { + return { valid: false, error: 'Vault path does not exist or is not accessible' }; + } + + let stats; + try { + stats = await stat(vaultPath); + } catch { + return { valid: false, error: 'Unable to read vault path info' }; + } + + if (!stats.isDirectory()) { + return { valid: false, error: 'Vault path must be a directory' }; + } + + // Resolve the real path to detect symlink escapes + let resolvedPath: string; + try { + resolvedPath = await realpath(vaultPath); + } catch { + return { valid: false, error: 'Unable to resolve vault path' }; + } + + // Check against sensitive directories + const normalizedResolved = path.normalize(resolvedPath); + for (const sensitiveDir of SENSITIVE_DIRECTORIES) { + const normalizedSensitive = path.normalize(sensitiveDir); + if ( + normalizedResolved === normalizedSensitive || + normalizedResolved.startsWith(normalizedSensitive + path.sep) + ) { + return { valid: false, error: `Vault path must not be within system directory: ${sensitiveDir}` }; + } + } + + return { valid: true }; +} + +/** + * Validate that a resolved file path is within the vault directory. + * Prevents path traversal attacks (e.g., ../../etc/passwd). + */ +export async function isPathWithinVault(filePath: string, vaultPath: string): Promise { + try { + const resolvedVault = await realpath(vaultPath); + const resolvedFile = path.resolve(resolvedVault, filePath); + + // The resolved file path must start with the vault path + // Add path.sep to prevent matching vault-name-prefix directories + return resolvedFile === resolvedVault || resolvedFile.startsWith(resolvedVault + path.sep); + } catch { + return false; + } +} + +/** + * Resolve and validate a file path within the vault. + * Returns the absolute resolved path or null if invalid. + */ +export async function resolveVaultFilePath( + relativePath: string, + vaultPath: string +): Promise { + try { + const resolvedVault = await realpath(vaultPath); + const resolvedFile = path.resolve(resolvedVault, relativePath); + + // Ensure the resolved path is within the vault + if (resolvedFile !== resolvedVault && !resolvedFile.startsWith(resolvedVault + path.sep)) { + return null; + } + + return resolvedFile; + } catch { + return null; + } +} + +/** + * Read CLAUDE.md from vault root for context injection. + * Returns null if the file does not exist or is too large. + */ +export async function readVaultClaudeMd(vaultPath: string): Promise { + try { + const claudeMdPath = path.join(vaultPath, 'CLAUDE.md'); + + // Validate the path is within vault + const resolvedVault = await realpath(vaultPath); + const resolvedClaudeMd = await realpath(claudeMdPath).catch(() => path.resolve(resolvedVault, 'CLAUDE.md')); + if (!resolvedClaudeMd.startsWith(resolvedVault + path.sep) && resolvedClaudeMd !== resolvedVault) { + return null; + } + + const fileStat = await stat(claudeMdPath); + if (fileStat.size > MAX_FILE_SIZE) { + return null; + } + + const content = await readFile(claudeMdPath, 'utf-8'); + return content.length > MAX_CLAUDE_MD_CHARS ? content.substring(0, MAX_CLAUDE_MD_CHARS) : content; + } catch { + return null; + } +} + +/** + * List learning files from vault/memory/learnings/ directory. + * Returns up to MAX_LEARNING_FILES files, each truncated to MAX_LEARNING_CHARS. + */ +export async function listVaultLearnings(vaultPath: string): Promise { + const learnings: VaultLearning[] = []; + + try { + const learningsDir = path.join(vaultPath, 'memory', 'learnings'); + + // Validate the path is within vault + const resolvedVault = await realpath(vaultPath); + const resolvedLearnings = await realpath(learningsDir).catch(() => + path.resolve(resolvedVault, 'memory', 'learnings') + ); + if (!resolvedLearnings.startsWith(resolvedVault + path.sep)) { + return []; + } + + let entries; + try { + entries = await readdir(learningsDir, { withFileTypes: true }); + } catch { + return []; + } + + // Filter to markdown files only + const mdFiles = entries + .filter((entry) => entry.isFile() && entry.name.endsWith('.md')) + .slice(0, MAX_LEARNING_FILES); + + for (const entry of mdFiles) { + try { + const filePath = path.join(learningsDir, entry.name); + + // Validate path is within vault + const resolvedFile = path.resolve(filePath); + if (!resolvedFile.startsWith(resolvedVault + path.sep)) { + continue; + } + + const fileStat = await stat(filePath); + if (fileStat.size > MAX_FILE_SIZE) { + continue; + } + + const content = await readFile(filePath, 'utf-8'); + const truncated = content.length > MAX_LEARNING_CHARS + ? content.substring(0, MAX_LEARNING_CHARS) + : content; + + learnings.push({ + name: entry.name, + path: path.join('memory', 'learnings', entry.name), + content: truncated, + }); + } catch { + // Skip files that can't be read + } + } + } catch { + // Return empty if the directory structure doesn't exist + } + + return learnings; +} + +/** + * List files and directories within a vault subdirectory. + */ +export async function listVaultFiles( + vaultPath: string, + subdirectory?: string +): Promise { + const resolvedVault = await realpath(vaultPath); + const targetDir = subdirectory + ? path.resolve(resolvedVault, subdirectory) + : resolvedVault; + + // Validate target is within vault + if (targetDir !== resolvedVault && !targetDir.startsWith(resolvedVault + path.sep)) { + throw new Error('Subdirectory path is outside vault'); + } + + const entries = await readdir(targetDir, { withFileTypes: true }); + const files: VaultFile[] = []; + + for (const entry of entries) { + // Skip hidden files/directories (e.g., .git, .obsidian) + if (entry.name.startsWith('.')) { + continue; + } + + try { + const fullPath = path.join(targetDir, entry.name); + + // Validate each entry path is within vault + const resolvedEntry = path.resolve(fullPath); + if (!resolvedEntry.startsWith(resolvedVault + path.sep)) { + continue; + } + + const entryStat = await stat(fullPath); + const relativePath = path.relative(resolvedVault, fullPath); + + files.push({ + name: entry.name, + path: relativePath, + size: entryStat.size, + modified: entryStat.mtime.toISOString(), + isDirectory: entryStat.isDirectory(), + }); + } catch { + // Skip entries that can't be stat'd + } + } + + // Sort: directories first, then by name + files.sort((a, b) => { + if (a.isDirectory !== b.isDirectory) { + return a.isDirectory ? -1 : 1; + } + return a.name.localeCompare(b.name); + }); + + return files; +} diff --git a/apps/desktop/src/main/ipc-handlers/vault/vault-handlers.ts b/apps/desktop/src/main/ipc-handlers/vault/vault-handlers.ts new file mode 100644 index 0000000000..8b7d1ac28d --- /dev/null +++ b/apps/desktop/src/main/ipc-handlers/vault/vault-handlers.ts @@ -0,0 +1,408 @@ +/** + * Vault IPC Handlers + * + * Handles all vault-related IPC communication between the renderer + * and main process. Provides read/write access to external vault + * directories for context injection and learning persistence. + */ + +import { ipcMain } from 'electron'; +import { readFile, writeFile, mkdir, stat, readdir, realpath } from 'fs/promises'; +import path from 'path'; +import type { IPCResult } from '../../../shared/types'; +import type { VaultConfig, VaultFile, VaultSearchResult, VaultContext } from './types'; +import { + isValidVaultPath, + resolveVaultFilePath, + readVaultClaudeMd, + listVaultLearnings, + listVaultFiles, + MAX_FILE_SIZE, +} from './utils'; + +// IPC channel names for vault operations +const VAULT_CHANNELS = { + VALIDATE_PATH: 'vault:validatePath', + LIST_FILES: 'vault:listFiles', + READ_FILE: 'vault:readFile', + SEARCH: 'vault:search', + GET_CONTEXT: 'vault:getContext', + SAVE_LEARNING: 'vault:saveLearning', +} as const; + +/** + * Validate vault config has required fields and path is valid. + * Used as a guard at the start of each handler. + */ +async function validateVaultConfig( + config: VaultConfig | null +): Promise<{ valid: true } | { valid: false; error: string }> { + if (!config) { + return { valid: false, error: 'Vault is not configured' }; + } + if (!config.enabled) { + return { valid: false, error: 'Vault integration is disabled' }; + } + const pathCheck = await isValidVaultPath(config.path); + if (!pathCheck.valid) { + return { valid: false, error: pathCheck.error ?? 'Invalid vault path' }; + } + return { valid: true }; +} + +/** + * Register all vault IPC handlers + */ +export function registerVaultIpcHandlers(): void { + // ------------------------------------------------------- + // VAULT_VALIDATE_PATH - Validate a vault directory path + // ------------------------------------------------------- + ipcMain.handle( + VAULT_CHANNELS.VALIDATE_PATH, + async (_event, vaultPath: string): Promise> => { + try { + if (typeof vaultPath !== 'string') { + return { success: false, error: 'Vault path must be a string' }; + } + + const result = await isValidVaultPath(vaultPath); + if (!result.valid) { + return { success: true, data: { valid: false, error: result.error } as { valid: boolean } }; + } + + return { success: true, data: { valid: true } }; + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error validating vault path'; + console.warn('[Vault] Error validating path:', message); + return { success: false, error: message }; + } + } + ); + + // ------------------------------------------------------- + // VAULT_LIST_FILES - List files in vault directory + // ------------------------------------------------------- + ipcMain.handle( + VAULT_CHANNELS.LIST_FILES, + async ( + _event, + config: VaultConfig, + subdirectory?: string + ): Promise> => { + try { + const validation = await validateVaultConfig(config); + if (!validation.valid) { + return { success: false, error: validation.error }; + } + + // Validate subdirectory if provided + if (subdirectory !== undefined && typeof subdirectory !== 'string') { + return { success: false, error: 'Subdirectory must be a string' }; + } + + const files = await listVaultFiles(config.path, subdirectory); + return { success: true, data: files }; + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error listing vault files'; + console.warn('[Vault] Error listing files:', message); + return { success: false, error: message }; + } + } + ); + + // ------------------------------------------------------- + // VAULT_READ_FILE - Read a file from the vault + // ------------------------------------------------------- + ipcMain.handle( + VAULT_CHANNELS.READ_FILE, + async ( + _event, + config: VaultConfig, + relativePath: string + ): Promise> => { + try { + const validation = await validateVaultConfig(config); + if (!validation.valid) { + return { success: false, error: validation.error }; + } + + if (typeof relativePath !== 'string' || !relativePath) { + return { success: false, error: 'File path must be a non-empty string' }; + } + + // Resolve and validate the path is within vault + const resolvedPath = await resolveVaultFilePath(relativePath, config.path); + if (!resolvedPath) { + return { success: false, error: 'File path is outside vault directory' }; + } + + // Check file exists and size + const fileStat = await stat(resolvedPath); + if (fileStat.isDirectory()) { + return { success: false, error: 'Path is a directory, not a file' }; + } + if (fileStat.size > MAX_FILE_SIZE) { + return { + success: false, + error: `File exceeds maximum size limit (${MAX_FILE_SIZE} bytes)`, + }; + } + + const content = await readFile(resolvedPath, 'utf-8'); + // Truncate to 50000 chars as defense-in-depth + const truncated = content.length > MAX_FILE_SIZE ? content.substring(0, MAX_FILE_SIZE) : content; + return { success: true, data: truncated }; + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error reading vault file'; + console.warn('[Vault] Error reading file:', message); + return { success: false, error: message }; + } + } + ); + + // ------------------------------------------------------- + // VAULT_SEARCH - Search vault files by name/content + // ------------------------------------------------------- + ipcMain.handle( + VAULT_CHANNELS.SEARCH, + async ( + _event, + config: VaultConfig, + query: string + ): Promise> => { + try { + const validation = await validateVaultConfig(config); + if (!validation.valid) { + return { success: false, error: validation.error }; + } + + if (typeof query !== 'string' || !query.trim()) { + return { success: false, error: 'Search query must be a non-empty string' }; + } + + const normalizedQuery = query.trim().toLowerCase(); + const matchingFiles: VaultFile[] = []; + + // Recursively search files in vault + await searchDirectory(config.path, config.path, normalizedQuery, matchingFiles); + + return { + success: true, + data: { + files: matchingFiles, + total: matchingFiles.length, + query, + }, + }; + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error searching vault'; + console.warn('[Vault] Error searching:', message); + return { success: false, error: message }; + } + } + ); + + // ------------------------------------------------------- + // VAULT_GET_CONTEXT - Get vault context for agent injection + // ------------------------------------------------------- + ipcMain.handle( + VAULT_CHANNELS.GET_CONTEXT, + async (_event, config: VaultConfig): Promise> => { + try { + const validation = await validateVaultConfig(config); + if (!validation.valid) { + return { success: false, error: validation.error }; + } + + const [claudeMd, learnings] = await Promise.all([ + readVaultClaudeMd(config.path), + listVaultLearnings(config.path), + ]); + + return { + success: true, + data: { claudeMd, learnings }, + }; + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error getting vault context'; + console.warn('[Vault] Error getting context:', message); + return { success: false, error: message }; + } + } + ); + + // ------------------------------------------------------- + // VAULT_SAVE_LEARNING - Save a learning to the vault + // ------------------------------------------------------- + ipcMain.handle( + VAULT_CHANNELS.SAVE_LEARNING, + async ( + _event, + config: VaultConfig, + fileName: string, + content: string + ): Promise> => { + try { + const validation = await validateVaultConfig(config); + if (!validation.valid) { + return { success: false, error: validation.error }; + } + + if (!config.writeEnabled) { + return { success: false, error: 'Write operations are not enabled for this vault' }; + } + + if (typeof fileName !== 'string' || !fileName.trim()) { + return { success: false, error: 'File name must be a non-empty string' }; + } + + if (typeof content !== 'string') { + return { success: false, error: 'Content must be a string' }; + } + + // Sanitize filename: only allow alphanumeric, hyphens, underscores, dots + const sanitizedName = fileName.replace(/[^a-zA-Z0-9._-]/g, '-'); + if (!sanitizedName) { + return { success: false, error: 'Invalid file name after sanitization' }; + } + + // Ensure .md extension + const finalName = sanitizedName.endsWith('.md') ? sanitizedName : `${sanitizedName}.md`; + + // Build target path within vault/memory/learnings/ + const learningsDir = path.join(config.path, 'memory', 'learnings'); + + // Validate the target directory is within vault + const resolvedVault = await realpath(config.path); + const resolvedLearningsDir = path.resolve(resolvedVault, 'memory', 'learnings'); + if (!resolvedLearningsDir.startsWith(resolvedVault + path.sep)) { + return { success: false, error: 'Learnings directory path is invalid' }; + } + + // Create directory if it doesn't exist + await mkdir(resolvedLearningsDir, { recursive: true }); + + const targetPath = path.join(resolvedLearningsDir, finalName); + + // Final path validation + if (!targetPath.startsWith(resolvedLearningsDir + path.sep)) { + return { success: false, error: 'Target file path is outside learnings directory' }; + } + + // Truncate content to prevent excessively large files + const maxWriteSize = MAX_FILE_SIZE; + const truncatedContent = content.length > maxWriteSize + ? content.substring(0, maxWriteSize) + : content; + + await writeFile(targetPath, truncatedContent, 'utf-8'); + + const relativePath = path.relative(resolvedVault, targetPath); + console.warn(`[Vault] Learning saved: ${relativePath}`); + + return { success: true, data: { path: relativePath } }; + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error saving learning'; + console.warn('[Vault] Error saving learning:', message); + return { success: false, error: message }; + } + } + ); +} + +// ------------------------------------------------------- +// Helper: Recursive directory search +// ------------------------------------------------------- + +/** Maximum search depth to prevent runaway recursion */ +const MAX_SEARCH_DEPTH = 5; + +/** Maximum number of search results */ +const MAX_SEARCH_RESULTS = 50; + +/** + * Recursively search a directory for files matching the query. + * Matches against file names and, for markdown files, file content. + */ +async function searchDirectory( + vaultRoot: string, + currentDir: string, + query: string, + results: VaultFile[], + depth: number = 0 +): Promise { + if (depth > MAX_SEARCH_DEPTH || results.length >= MAX_SEARCH_RESULTS) { + return; + } + + const resolvedVault = await realpath(vaultRoot); + + let entries; + try { + entries = await readdir(currentDir, { withFileTypes: true }); + } catch { + return; + } + + for (const entry of entries) { + if (results.length >= MAX_SEARCH_RESULTS) { + break; + } + + // Skip hidden files/directories + if (entry.name.startsWith('.')) { + continue; + } + + const fullPath = path.join(currentDir, entry.name); + + // Validate path is within vault + const resolvedEntry = path.resolve(fullPath); + if (!resolvedEntry.startsWith(resolvedVault + path.sep)) { + continue; + } + + if (entry.isDirectory()) { + // Recurse into subdirectories + await searchDirectory(vaultRoot, fullPath, query, results, depth + 1); + } else if (entry.isFile()) { + let matched = false; + + // Match against file name + if (entry.name.toLowerCase().includes(query)) { + matched = true; + } + + // For markdown files, also check content + if (!matched && entry.name.endsWith('.md')) { + try { + const entryStat = await stat(fullPath); + if (entryStat.size <= MAX_FILE_SIZE) { + const fileContent = await readFile(fullPath, 'utf-8'); + if (fileContent.toLowerCase().includes(query)) { + matched = true; + } + } + } catch { + // Skip files that can't be read + } + } + + if (matched) { + try { + const entryStat = await stat(fullPath); + const relativePath = path.relative(resolvedVault, fullPath); + results.push({ + name: entry.name, + path: relativePath, + size: entryStat.size, + modified: entryStat.mtime.toISOString(), + isDirectory: false, + }); + } catch { + // Skip entries that can't be stat'd + } + } + } + } +} diff --git a/apps/desktop/src/preload/api/agent-api.ts b/apps/desktop/src/preload/api/agent-api.ts index f9af4fadfb..431fba74ce 100644 --- a/apps/desktop/src/preload/api/agent-api.ts +++ b/apps/desktop/src/preload/api/agent-api.ts @@ -19,6 +19,8 @@ import { createLinearAPI, LinearAPI } from './modules/linear-api'; import { createGitHubAPI, GitHubAPI } from './modules/github-api'; import { createGitLabAPI, GitLabAPI } from './modules/gitlab-api'; import { createShellAPI, ShellAPI } from './modules/shell-api'; +import { createJiraAPI, JiraAPI } from './modules/jira-api'; +import { createVaultAPI, VaultAPI } from './modules/vault-api'; /** * Combined Agent API interface @@ -32,7 +34,9 @@ export interface AgentAPI extends LinearAPI, GitHubAPI, GitLabAPI, - ShellAPI {} + ShellAPI, + JiraAPI, + VaultAPI {} /** * Creates the complete Agent API by combining all module APIs @@ -48,6 +52,8 @@ export const createAgentAPI = (): AgentAPI => { const githubAPI = createGitHubAPI(); const gitlabAPI = createGitLabAPI(); const shellAPI = createShellAPI(); + const jiraAPI = createJiraAPI(); + const vaultAPI = createVaultAPI(); return { // Roadmap API @@ -72,7 +78,13 @@ export const createAgentAPI = (): AgentAPI => { ...gitlabAPI, // Shell Operations API - ...shellAPI + ...shellAPI, + + // JIRA Integration API + ...jiraAPI, + + // Vault Integration API + ...vaultAPI, }; }; diff --git a/apps/desktop/src/preload/api/modules/index.ts b/apps/desktop/src/preload/api/modules/index.ts index e2cc553781..2aa103a182 100644 --- a/apps/desktop/src/preload/api/modules/index.ts +++ b/apps/desktop/src/preload/api/modules/index.ts @@ -13,3 +13,5 @@ export * from './linear-api'; export * from './github-api'; export * from './shell-api'; export * from './debug-api'; +export * from './jira-api'; +export * from './vault-api'; diff --git a/apps/desktop/src/preload/api/modules/jira-api.ts b/apps/desktop/src/preload/api/modules/jira-api.ts new file mode 100644 index 0000000000..47a070e659 --- /dev/null +++ b/apps/desktop/src/preload/api/modules/jira-api.ts @@ -0,0 +1,87 @@ +import { ipcRenderer } from 'electron'; +import { IPC_CHANNELS } from '../../../shared/constants'; +import type { IPCResult } from '../../../shared/types'; +import { invokeIpc } from './ipc-utils'; + +/** + * JIRA Integration API operations + */ +export interface JiraAPI { + jiraTestConnection: (projectId: string) => Promise>; + jiraListProjects: (projectId: string) => Promise>>; + jiraSearchIssues: (projectId: string, jql: string) => Promise>; total: number }>>; + jiraGetIssue: (projectId: string, issueKey: string) => Promise>>; + jiraGetIssueComments: (projectId: string, issueKey: string) => Promise>>>; + jiraCreateIssue: (projectId: string, fields: Record) => Promise>; + jiraAddComment: (projectId: string, issueKey: string, body: string) => Promise; + jiraGetTransitions: (projectId: string, issueKey: string) => Promise>>; + jiraTransitionIssue: (projectId: string, issueKey: string, transitionId: string) => Promise; + // Investigation (async - fires events) + investigateJiraIssue: (projectId: string, issueKey: string, selectedCommentIds?: string[]) => void; + // Event listeners for investigation progress + onJiraInvestigationProgress: (callback: (projectId: string, status: { phase: string; progress: number; message: string }) => void) => () => void; + onJiraInvestigationComplete: (callback: (projectId: string, result: { taskId: string; specId: string }) => void) => () => void; + onJiraInvestigationError: (callback: (projectId: string, error: string) => void) => () => void; +} + +/** + * Creates the JIRA Integration API implementation + */ +export const createJiraAPI = (): JiraAPI => ({ + jiraTestConnection: (projectId: string): Promise> => + invokeIpc(IPC_CHANNELS.JIRA_TEST_CONNECTION, projectId), + + jiraListProjects: (projectId: string): Promise>> => + invokeIpc(IPC_CHANNELS.JIRA_LIST_PROJECTS, projectId), + + jiraSearchIssues: (projectId: string, jql: string): Promise>; total: number }>> => + invokeIpc(IPC_CHANNELS.JIRA_SEARCH_ISSUES, projectId, jql), + + jiraGetIssue: (projectId: string, issueKey: string): Promise>> => + invokeIpc(IPC_CHANNELS.JIRA_GET_ISSUE, projectId, issueKey), + + jiraGetIssueComments: (projectId: string, issueKey: string): Promise>>> => + invokeIpc(IPC_CHANNELS.JIRA_GET_ISSUE_COMMENTS, projectId, issueKey), + + jiraCreateIssue: (projectId: string, fields: Record): Promise> => + invokeIpc(IPC_CHANNELS.JIRA_CREATE_ISSUE, projectId, fields), + + jiraAddComment: (projectId: string, issueKey: string, body: string): Promise => + invokeIpc(IPC_CHANNELS.JIRA_ADD_COMMENT, projectId, issueKey, body), + + jiraGetTransitions: (projectId: string, issueKey: string): Promise>> => + invokeIpc(IPC_CHANNELS.JIRA_GET_TRANSITIONS, projectId, issueKey), + + jiraTransitionIssue: (projectId: string, issueKey: string, transitionId: string): Promise => + invokeIpc(IPC_CHANNELS.JIRA_TRANSITION_ISSUE, projectId, issueKey, transitionId), + + // Investigation (one-way send, results come via events) + investigateJiraIssue: (projectId: string, issueKey: string, selectedCommentIds?: string[]): void => { + ipcRenderer.send(IPC_CHANNELS.JIRA_INVESTIGATE_ISSUE, projectId, issueKey, selectedCommentIds); + }, + + // Event listeners + onJiraInvestigationProgress: (callback) => { + const handler = (_event: Electron.IpcRendererEvent, projectId: string, status: { phase: string; progress: number; message: string }) => { + callback(projectId, status); + }; + ipcRenderer.on(IPC_CHANNELS.JIRA_INVESTIGATION_PROGRESS, handler); + return () => { ipcRenderer.removeListener(IPC_CHANNELS.JIRA_INVESTIGATION_PROGRESS, handler); }; + }, + + onJiraInvestigationComplete: (callback) => { + const handler = (_event: Electron.IpcRendererEvent, projectId: string, result: { taskId: string; specId: string }) => { + callback(projectId, result); + }; + ipcRenderer.on(IPC_CHANNELS.JIRA_INVESTIGATION_COMPLETE, handler); + return () => { ipcRenderer.removeListener(IPC_CHANNELS.JIRA_INVESTIGATION_COMPLETE, handler); }; + }, + + onJiraInvestigationError: (callback) => { + const handler = (_event: Electron.IpcRendererEvent, projectId: string, error: string) => { + callback(projectId, error); + }; + ipcRenderer.on(IPC_CHANNELS.JIRA_INVESTIGATION_ERROR, handler); + return () => { ipcRenderer.removeListener(IPC_CHANNELS.JIRA_INVESTIGATION_ERROR, handler); }; + }, +}); diff --git a/apps/desktop/src/preload/api/modules/vault-api.ts b/apps/desktop/src/preload/api/modules/vault-api.ts new file mode 100644 index 0000000000..9f97afdd3c --- /dev/null +++ b/apps/desktop/src/preload/api/modules/vault-api.ts @@ -0,0 +1,38 @@ +import { IPC_CHANNELS } from '../../../shared/constants'; +import type { IPCResult } from '../../../shared/types'; +import { invokeIpc } from './ipc-utils'; + +/** + * Vault Integration API operations + */ +export interface VaultAPI { + vaultValidatePath: (vaultPath: string) => Promise>; + vaultListFiles: (vaultPath: string, subdir?: string) => Promise>>; + vaultReadFile: (vaultPath: string, filePath: string) => Promise>; + vaultSearch: (vaultPath: string, query: string) => Promise>>; + vaultGetContext: (vaultPath: string) => Promise>; + vaultSaveLearning: (vaultPath: string, filename: string, content: string) => Promise; +} + +/** + * Creates the Vault Integration API implementation + */ +export const createVaultAPI = (): VaultAPI => ({ + vaultValidatePath: (vaultPath: string): Promise> => + invokeIpc(IPC_CHANNELS.VAULT_VALIDATE_PATH, vaultPath), + + vaultListFiles: (vaultPath: string, subdir?: string): Promise>> => + invokeIpc(IPC_CHANNELS.VAULT_LIST_FILES, vaultPath, subdir), + + vaultReadFile: (vaultPath: string, filePath: string): Promise> => + invokeIpc(IPC_CHANNELS.VAULT_READ_FILE, vaultPath, filePath), + + vaultSearch: (vaultPath: string, query: string): Promise>> => + invokeIpc(IPC_CHANNELS.VAULT_SEARCH, vaultPath, query), + + vaultGetContext: (vaultPath: string): Promise> => + invokeIpc(IPC_CHANNELS.VAULT_GET_CONTEXT, vaultPath), + + vaultSaveLearning: (vaultPath: string, filename: string, content: string): Promise => + invokeIpc(IPC_CHANNELS.VAULT_SAVE_LEARNING, vaultPath, filename, content), +}); diff --git a/apps/desktop/src/renderer/App.tsx b/apps/desktop/src/renderer/App.tsx index 021d9b1440..3e570ffa0d 100644 --- a/apps/desktop/src/renderer/App.tsx +++ b/apps/desktop/src/renderer/App.tsx @@ -41,6 +41,7 @@ import { Insights } from './components/Insights'; import { ErrorBoundary } from './components/ui/error-boundary'; import { GitHubIssues } from './components/GitHubIssues'; import { GitLabIssues } from './components/GitLabIssues'; +import { JiraIssues } from './components/JiraIssues'; import { GitHubPRs } from './components/github-prs'; import { GitLabMergeRequests } from './components/gitlab-merge-requests'; import { Changelog } from './components/Changelog'; @@ -910,7 +911,7 @@ export function App() { {activeView === 'github-issues' && (activeProjectId || selectedProjectId) && ( { - setSettingsInitialProjectSection('github'); + setSettingsInitialProjectSection('source-control'); setIsSettingsDialogOpen(true); }} onNavigateToTask={handleGoToTask} @@ -919,7 +920,16 @@ export function App() { {activeView === 'gitlab-issues' && (activeProjectId || selectedProjectId) && ( { - setSettingsInitialProjectSection('gitlab'); + setSettingsInitialProjectSection('source-control'); + setIsSettingsDialogOpen(true); + }} + onNavigateToTask={handleGoToTask} + /> + )} + {activeView === 'jira-issues' && (activeProjectId || selectedProjectId) && ( + { + setSettingsInitialProjectSection('issue-tracking'); setIsSettingsDialogOpen(true); }} onNavigateToTask={handleGoToTask} @@ -930,7 +940,7 @@ export function App() {
{ - setSettingsInitialProjectSection('github'); + setSettingsInitialProjectSection('source-control'); setIsSettingsDialogOpen(true); }} isActive={activeView === 'github-prs'} @@ -941,7 +951,7 @@ export function App() { { - setSettingsInitialProjectSection('gitlab'); + setSettingsInitialProjectSection('source-control'); setIsSettingsDialogOpen(true); }} /> diff --git a/apps/desktop/src/renderer/components/JiraIssues.tsx b/apps/desktop/src/renderer/components/JiraIssues.tsx new file mode 100644 index 0000000000..9a86614c17 --- /dev/null +++ b/apps/desktop/src/renderer/components/JiraIssues.tsx @@ -0,0 +1,428 @@ +import { useState, useEffect, useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Search, + RefreshCw, + AlertCircle, + Settings, + Loader2, + Sparkles, + ExternalLink +} from 'lucide-react'; +import { useProjectStore } from '../stores/project-store'; +import { useProjectEnvStore } from '../stores/project-env-store'; +import { useTaskStore } from '../stores/task-store'; +import { + useJiraStore, + loadJiraIssues, + checkJiraConnection, + type JiraIssue +} from '../stores/jira-store'; +import { JiraInvestigationDialog } from './jira-issues/components/InvestigationDialog'; +import { Badge } from './ui/badge'; +import { Button } from './ui/button'; +import { Input } from './ui/input'; +import { ScrollArea } from './ui/scroll-area'; + +interface JiraIssuesProps { + onOpenSettings: () => void; + onNavigateToTask: (taskId: string) => void; +} + +function formatDate(dateStr: string): string { + if (!dateStr) return ''; + try { + const date = new Date(dateStr); + return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); + } catch { + return dateStr; + } +} + +function getStatusBadgeVariant(status: string): 'success' | 'secondary' | 'info' | 'warning' | 'muted' { + const lower = status.toLowerCase(); + if (['done', 'closed', 'resolved', 'complete'].includes(lower)) return 'success'; + if (['in progress', 'in review'].includes(lower)) return 'info'; + if (['to do', 'open', 'backlog', 'new'].includes(lower)) return 'secondary'; + if (['blocked', 'on hold'].includes(lower)) return 'warning'; + return 'muted'; +} + +function getPriorityBadgeVariant(priority?: string): 'destructive' | 'warning' | 'secondary' | 'muted' { + if (!priority) return 'muted'; + const lower = priority.toLowerCase(); + if (['highest', 'blocker', 'critical'].includes(lower)) return 'destructive'; + if (['high', 'major'].includes(lower)) return 'warning'; + return 'secondary'; +} + +export function JiraIssues({ onOpenSettings, onNavigateToTask }: JiraIssuesProps) { + const { t } = useTranslation('jira'); + const projects = useProjectStore((state) => state.projects); + const selectedProjectId = useProjectStore((state) => state.selectedProjectId); + const selectedProject = projects.find((p) => p.id === selectedProjectId); + const envConfig = useProjectEnvStore((state) => state.envConfig); + const tasks = useTaskStore((state) => state.tasks); + + const connected = useJiraStore((state) => state.connected); + const isLoading = useJiraStore((state) => state.isLoading); + const error = useJiraStore((state) => state.error); + const selectedIssueKey = useJiraStore((state) => state.selectedIssueKey); + const filterState = useJiraStore((state) => state.filterState); + const searchQuery = useJiraStore((state) => state.searchQuery); + const selectIssue = useJiraStore((state) => state.selectIssue); + const setFilterState = useJiraStore((state) => state.setFilterState); + const setSearchQuery = useJiraStore((state) => state.setSearchQuery); + const getFilteredIssues = useJiraStore((state) => state.getFilteredIssues); + const getSelectedIssue = useJiraStore((state) => state.getSelectedIssue); + const getOpenIssuesCount = useJiraStore((state) => state.getOpenIssuesCount); + + const [showInvestigateDialog, setShowInvestigateDialog] = useState(false); + const [issueForInvestigation, setIssueForInvestigation] = useState(null); + const [assignedToMe, setAssignedToMe] = useState(false); + + const jiraProjectKey = envConfig?.jiraProjectKey || ''; + const jiraUserName = envConfig?.jiraEmail?.split('@')[0] || ''; + + const allFilteredIssues = getFilteredIssues(); + const filteredIssues = assignedToMe + ? allFilteredIssues.filter(i => i.assignee?.toLowerCase().includes(jiraUserName.toLowerCase())) + : allFilteredIssues; + const selectedIssue = getSelectedIssue(); + const openCount = getOpenIssuesCount(); + const jiraEnabled = envConfig?.jiraEnabled || false; + + // Build a map of JIRA issue keys to task IDs for quick lookup + const issueToTaskMap = useMemo(() => { + const map = new Map(); + for (const task of tasks) { + if (task.metadata?.jiraIssueKey) { + map.set(task.metadata.jiraIssueKey, task.specId || task.id); + } + } + return map; + }, [tasks]); + + // Check connection and load issues on mount + useEffect(() => { + if (!selectedProject?.id || !jiraEnabled || !jiraProjectKey) return; + + const init = async () => { + const isConnected = await checkJiraConnection(selectedProject.id); + if (isConnected) { + await loadJiraIssues(selectedProject.id, jiraProjectKey); + } + }; + init(); + }, [selectedProject?.id, jiraEnabled, jiraProjectKey]); + + const handleRefresh = useCallback(() => { + if (!selectedProject?.id || !jiraProjectKey) return; + loadJiraIssues(selectedProject.id, jiraProjectKey); + }, [selectedProject?.id, jiraProjectKey]); + + const handleFilterChange = useCallback((newFilter: 'open' | 'closed' | 'all') => { + setFilterState(newFilter); + if (selectedProject?.id && jiraProjectKey) { + loadJiraIssues(selectedProject.id, jiraProjectKey, newFilter); + } + }, [selectedProject?.id, jiraProjectKey, setFilterState]); + + const handleInvestigate = useCallback((issue: JiraIssue) => { + setIssueForInvestigation(issue); + setShowInvestigateDialog(true); + }, []); + + // Not connected state + if (!jiraEnabled || !connected) { + return ( +
+
+ +

{t('notConnected.title')}

+

{t('notConnected.description')}

+ +
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+
+

{jiraProjectKey}

+ + {openCount} {t('header.open')} + +
+
+ + {/* Filter buttons */} +
+ {(['open', 'closed', 'all'] as const).map((filter) => ( + + ))} + +
+ + {/* Search */} +
+ + setSearchQuery(e.target.value)} + className="h-7 pl-7 text-xs" + /> +
+ + {/* Refresh */} + +
+ + {/* Content */} +
+ {/* Issue List */} +
+ {isLoading && filteredIssues.length === 0 ? ( +
+ +
+ ) : error ? ( +
+
+ +

{error}

+
+
+ ) : filteredIssues.length === 0 ? ( +
+

{t('empty.noMatch')}

+
+ ) : ( + +
+ {filteredIssues.map((issue) => { + const linkedTaskId = issueToTaskMap.get(issue.key); + return ( + + ); + })} +
+
+ )} +
+ + {/* Issue Detail */} +
+ {selectedIssue ? ( + handleInvestigate(selectedIssue)} + onViewTask={onNavigateToTask} + /> + ) : ( +
+

{t('empty.selectIssue')}

+
+ )} +
+
+ + {/* Investigation Dialog */} + {issueForInvestigation && selectedProject?.id && ( + + )} +
+ ); +} + +// ---- Issue Detail Panel (inline sub-component) ---- + +interface IssueDetailPanelProps { + issue: JiraIssue; + linkedTaskId?: string; + onInvestigate: () => void; + onViewTask: (taskId: string) => void; +} + +function IssueDetailPanel({ issue, linkedTaskId, onInvestigate, onViewTask }: IssueDetailPanelProps) { + const { t } = useTranslation('jira'); + + return ( + +
+ {/* Header */} +
+
+ {issue.key} + + {issue.status} + + {issue.priority && ( + + {issue.priority} + + )} +
+

{issue.summary}

+
+ + {/* Actions */} +
+ {linkedTaskId ? ( + + ) : ( + + )} +
+ + {/* Linked task info */} + {linkedTaskId && ( +
+

{t('detail.taskLinked')}

+

{t('detail.taskId')}: {linkedTaskId}

+
+ )} + + {/* Metadata */} +
+
+
+ {t('detail.type')} +

{issue.issueType}

+
+
+ {t('detail.status')} +

{issue.status}

+
+ {issue.assignee && ( +
+ {t('detail.assignee')} +

{issue.assignee}

+
+ )} + {issue.priority && ( +
+ {t('detail.priority')} +

{issue.priority}

+
+ )} +
+ {t('detail.created')} +

{formatDate(issue.created)}

+
+
+ {t('detail.updated')} +

{formatDate(issue.updated)}

+
+
+ + {/* Labels */} + {issue.labels.length > 0 && ( +
+ {t('detail.labels')} +
+ {issue.labels.map((label) => ( + + {label} + + ))} +
+
+ )} +
+ + {/* Description */} +
+

{t('detail.description')}

+ {issue.description ? ( +
+ {issue.description} +
+ ) : ( +

{t('detail.noDescription')}

+ )} +
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/components/Sidebar.tsx b/apps/desktop/src/renderer/components/Sidebar.tsx index c156d8697d..0c7f27f01d 100644 --- a/apps/desktop/src/renderer/components/Sidebar.tsx +++ b/apps/desktop/src/renderer/components/Sidebar.tsx @@ -22,7 +22,8 @@ import { Heart, Wrench, PanelLeft, - PanelLeftClose + PanelLeftClose, + Ticket } from 'lucide-react'; import { Button } from './ui/button'; import { ScrollArea } from './ui/scroll-area'; @@ -60,7 +61,7 @@ import { RateLimitIndicator } from './RateLimitIndicator'; import { UpdateBanner } from './UpdateBanner'; import type { Project, GitStatus } from '../../shared/types'; -export type SidebarView = 'kanban' | 'terminals' | 'roadmap' | 'context' | 'ideation' | 'github-issues' | 'gitlab-issues' | 'github-prs' | 'gitlab-merge-requests' | 'changelog' | 'insights' | 'worktrees' | 'agent-tools'; +export type SidebarView = 'kanban' | 'terminals' | 'roadmap' | 'context' | 'ideation' | 'github-issues' | 'gitlab-issues' | 'jira-issues' | 'github-prs' | 'gitlab-merge-requests' | 'changelog' | 'insights' | 'worktrees' | 'agent-tools'; interface SidebarProps { onSettingsClick: () => void; @@ -89,17 +90,14 @@ const baseNavItems: NavItem[] = [ { id: 'worktrees', labelKey: 'navigation:items.worktrees', icon: GitBranch, shortcut: 'W' } ]; -// GitHub nav items shown when GitHub is enabled -const githubNavItems: NavItem[] = [ - { id: 'github-issues', labelKey: 'navigation:items.githubIssues', icon: Github, shortcut: 'G' }, - { id: 'github-prs', labelKey: 'navigation:items.githubPRs', icon: GitPullRequest, shortcut: 'P' } -]; +// Source control nav items (PRs/MRs - shown when source control provider is enabled) +const githubPRsNavItem: NavItem = { id: 'github-prs', labelKey: 'navigation:items.githubPRs', icon: GitPullRequest, shortcut: 'P' }; +const gitlabMRsNavItem: NavItem = { id: 'gitlab-merge-requests', labelKey: 'navigation:items.gitlabMRs', icon: GitMerge, shortcut: 'R' }; -// GitLab nav items shown when GitLab is enabled -const gitlabNavItems: NavItem[] = [ - { id: 'gitlab-issues', labelKey: 'navigation:items.gitlabIssues', icon: GitlabIcon, shortcut: 'B' }, - { id: 'gitlab-merge-requests', labelKey: 'navigation:items.gitlabMRs', icon: GitMerge, shortcut: 'R' } -]; +// Issue tracking nav items (shown independently based on issue tracker config) +const githubIssuesNavItem: NavItem = { id: 'github-issues', labelKey: 'navigation:items.githubIssues', icon: Github, shortcut: 'G' }; +const gitlabIssuesNavItem: NavItem = { id: 'gitlab-issues', labelKey: 'navigation:items.gitlabIssues', icon: GitlabIcon, shortcut: 'B' }; +const jiraIssuesNavItem: NavItem = { id: 'jira-issues', labelKey: 'navigation:items.jiraIssues', icon: Ticket, shortcut: 'J' }; export function Sidebar({ onSettingsClick, @@ -128,27 +126,43 @@ export function Sidebar({ saveSettings({ sidebarCollapsed: !isCollapsed }); }; - // Subscribe to project-env-store for reactive GitHub/GitLab tab visibility + // Subscribe to project-env-store for reactive nav visibility const githubEnabled = useProjectEnvStore((state) => state.envConfig?.githubEnabled ?? false); const gitlabEnabled = useProjectEnvStore((state) => state.envConfig?.gitlabEnabled ?? false); + const githubIssuesEnabled = useProjectEnvStore((state) => state.envConfig?.githubIssuesEnabled ?? false); + const gitlabIssuesEnabled = useProjectEnvStore((state) => state.envConfig?.gitlabIssuesEnabled ?? false); + const jiraEnabled = useProjectEnvStore((state) => state.envConfig?.jiraEnabled ?? false); // Track the last loaded project ID to avoid redundant loads const lastLoadedProjectIdRef = useRef(null); - // Compute visible nav items based on GitHub/GitLab enabled state from store + // Compute visible nav items based on integration config + // Source control (PRs/MRs) and issue tracking are independent const visibleNavItems = useMemo(() => { const items = [...baseNavItems]; + // Source control: show PRs/MRs based on which provider is enabled if (githubEnabled) { - items.push(...githubNavItems); + items.push(githubPRsNavItem); } - if (gitlabEnabled) { - items.push(...gitlabNavItems); + items.push(gitlabMRsNavItem); + } + + // Issue tracking: show based on which issue tracker is enabled (independent of source control) + if (githubIssuesEnabled) { + items.push(githubIssuesNavItem); + } + if (gitlabIssuesEnabled) { + items.push(gitlabIssuesNavItem); + } + // JIRA issue tracking + if (jiraEnabled) { + items.push(jiraIssuesNavItem); } return items; - }, [githubEnabled, gitlabEnabled]); + }, [githubEnabled, gitlabEnabled, githubIssuesEnabled, gitlabIssuesEnabled, jiraEnabled]); // Load envConfig when project changes to ensure store is populated useEffect(() => { diff --git a/apps/desktop/src/renderer/components/jira-issues/components/InvestigationDialog.tsx b/apps/desktop/src/renderer/components/jira-issues/components/InvestigationDialog.tsx new file mode 100644 index 0000000000..f6ab9e36fd --- /dev/null +++ b/apps/desktop/src/renderer/components/jira-issues/components/InvestigationDialog.tsx @@ -0,0 +1,185 @@ +import { useEffect, useState, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Sparkles, Loader2, CheckCircle2 } from 'lucide-react'; +import { Button } from '../../ui/button'; +import { Progress } from '../../ui/progress'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '../../ui/dialog'; + +interface JiraInvestigationDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + issueKey: string; + issueSummary: string; + projectId: string; +} + +type InvestigationPhase = 'idle' | 'fetching' | 'creating_task' | 'complete' | 'error'; + +export function JiraInvestigationDialog({ + open, + onOpenChange, + issueKey, + issueSummary, + projectId +}: JiraInvestigationDialogProps) { + const { t } = useTranslation('jira'); + const [phase, setPhase] = useState('idle'); + const [progress, setProgress] = useState(0); + const [message, setMessage] = useState(''); + const [error, setError] = useState(''); + + // Reset state when dialog opens + useEffect(() => { + if (open) { + setPhase('idle'); + setProgress(0); + setMessage(''); + setError(''); + } + }, [open]); + + // Listen for backend investigation events + useEffect(() => { + if (!open || phase === 'idle') return; + + const removeProgress = window.electronAPI.onJiraInvestigationProgress( + (projId, status) => { + if (projId !== projectId) return; + setProgress(status.progress); + setMessage(status.message); + if (status.phase === 'complete') { + setPhase('complete'); + } else { + setPhase(status.phase as InvestigationPhase); + } + } + ); + + const removeComplete = window.electronAPI.onJiraInvestigationComplete( + (projId, _result) => { + if (projId !== projectId) return; + setPhase('complete'); + setProgress(100); + setMessage(t('investigation.taskCreated')); + } + ); + + const removeError = window.electronAPI.onJiraInvestigationError( + (projId, err) => { + if (projId !== projectId) return; + setPhase('error'); + setError(err); + } + ); + + return () => { + removeProgress(); + removeComplete(); + removeError(); + }; + }, [open, phase, projectId, t]); + + const handleStartInvestigation = useCallback(() => { + setPhase('fetching'); + setProgress(10); + setMessage(t('investigation.fetchingDetails')); + // Fire and forget - backend sends progress events + window.electronAPI.investigateJiraIssue(projectId, issueKey); + }, [projectId, issueKey, t]); + + const handleClose = useCallback(() => { + onOpenChange(false); + }, [onOpenChange]); + + return ( + + + + + + {t('investigation.title')} + + + {issueKey}: {issueSummary} + + + + {phase === 'idle' ? ( +
+

+ {t('investigation.description')} +

+
+

{t('investigation.willInclude')}

+
    +
  • - {t('investigation.includeTitle')}
  • +
  • - {t('investigation.includeLink')}
  • +
  • - {t('investigation.includeLabels')}
  • +
+
+
+ ) : ( +
+
+
+ {message} + {progress}% +
+ +
+ + {phase === 'error' && ( +
+ {error} +
+ )} + + {phase === 'complete' && ( +
+ + {t('investigation.taskCreated')} +
+ )} +
+ )} + + + {phase === 'idle' && ( + <> + + + + )} + {phase !== 'idle' && phase !== 'complete' && phase !== 'error' && ( + + )} + {phase === 'error' && ( + + )} + {phase === 'complete' && ( + + )} + +
+
+ ); +} diff --git a/apps/desktop/src/renderer/components/jira-issues/index.ts b/apps/desktop/src/renderer/components/jira-issues/index.ts new file mode 100644 index 0000000000..6169cba297 --- /dev/null +++ b/apps/desktop/src/renderer/components/jira-issues/index.ts @@ -0,0 +1,5 @@ +// Main export for the jira-issues module +export { JiraIssues } from '../JiraIssues'; + +// Re-export components +export { JiraInvestigationDialog } from './components/InvestigationDialog'; diff --git a/apps/desktop/src/renderer/components/settings/AppSettings.tsx b/apps/desktop/src/renderer/components/settings/AppSettings.tsx index 5d1a4b92dc..c5dd2d19f0 100644 --- a/apps/desktop/src/renderer/components/settings/AppSettings.tsx +++ b/apps/desktop/src/renderer/components/settings/AppSettings.tsx @@ -19,7 +19,11 @@ import { Code, Bug, Terminal, - Users + Users, + GitBranch, + ClipboardList, + BrainCircuit, + CheckCircle2 } from 'lucide-react'; // GitLab icon component (lucide-react doesn't have one) @@ -90,10 +94,9 @@ const appNavItemsConfig: NavItemConfig[] = [ const projectNavItemsConfig: NavItemConfig[] = [ { id: 'general', icon: Settings2 }, - { id: 'linear', icon: Zap }, - { id: 'github', icon: Github }, - { id: 'gitlab', icon: GitLabIcon }, - { id: 'memory', icon: Database } + { id: 'source-control', icon: GitBranch }, + { id: 'issue-tracking', icon: ClipboardList }, + { id: 'memory-context', icon: BrainCircuit }, ]; /** @@ -148,24 +151,36 @@ export function AppSettingsDialog({ open, onOpenChange, initialSection, initialP } }, []); + const [isSavingAll, setIsSavingAll] = useState(false); + const [saveSuccess, setSaveSuccess] = useState(false); + const handleSave = async () => { - // Save app settings first - const appSaveSuccess = await saveSettings(); - - // If on project section with a project selected, save project settings too - if (activeTopLevel === 'project' && selectedProject && projectSettingsHook) { - await projectSettingsHook.handleSave(() => {}); - // Check for project errors - if (projectSettingsHook.error || projectSettingsHook.envError) { - setProjectError(projectSettingsHook.error || projectSettingsHook.envError); - return; // Don't close dialog on error + setIsSavingAll(true); + setSaveSuccess(false); + setProjectError(null); + + try { + // Save app settings first + const appSaveSuccess = await saveSettings(); + + // If on project section with a project selected, save project settings too + if (activeTopLevel === 'project' && selectedProject && projectSettingsHook) { + await projectSettingsHook.handleSave(() => {}); + // Check for project errors + if (projectSettingsHook.error || projectSettingsHook.envError) { + setProjectError(projectSettingsHook.error || projectSettingsHook.envError); + return; + } } - } - if (appSaveSuccess) { - // Commit the theme so future cancels won't revert to old values - commitTheme(); - onOpenChange(false); + if (appSaveSuccess) { + commitTheme(); + setSaveSuccess(true); + // Clear success indicator after 2 seconds + setTimeout(() => setSaveSuccess(false), 2000); + } + } finally { + setIsSavingAll(false); } }; @@ -386,13 +401,18 @@ export function AppSettingsDialog({ open, onOpenChange, initialSection, initialP +
+ + {/* Validation Status */} + {validationResult && ( +
+ {validationResult.valid ? ( + <> + + {t('vault.pathValid')} + + ) : ( + <> + + {validationResult.error} + + )} +
+ )} + + + {/* Sync Learnings Toggle */} +
+
+ +

+ {t('vault.syncLearningsDescription')} +

+
+ updateSetting('vaultSyncLearnings', checked)} + /> +
+ + {/* Auto-Load Context Toggle */} +
+
+ +

+ {t('vault.autoLoadDescription')} +

+
+ updateSetting('vaultAutoLoad', checked)} + /> +
+ + {/* Allow Write Operations Toggle */} +
+
+ +

+ {t('vault.writeEnabledDescription')} +

+
+ updateSetting('vaultWriteEnabled', checked)} + /> +
+ + )} + + + ); +} diff --git a/apps/desktop/src/renderer/components/settings/integrations/index.ts b/apps/desktop/src/renderer/components/settings/integrations/index.ts index 58aea97e55..a3789b3d62 100644 --- a/apps/desktop/src/renderer/components/settings/integrations/index.ts +++ b/apps/desktop/src/renderer/components/settings/integrations/index.ts @@ -5,3 +5,4 @@ export { LinearIntegration } from './LinearIntegration'; export { GitHubIntegration } from './GitHubIntegration'; +export { JiraIntegration } from './JiraIntegration'; diff --git a/apps/desktop/src/renderer/components/settings/sections/SectionRouter.tsx b/apps/desktop/src/renderer/components/settings/sections/SectionRouter.tsx index 27dbdd8a0d..8564f87e76 100644 --- a/apps/desktop/src/renderer/components/settings/sections/SectionRouter.tsx +++ b/apps/desktop/src/renderer/components/settings/sections/SectionRouter.tsx @@ -3,10 +3,11 @@ import type { Project, ProjectSettings as ProjectSettingsType, AutoBuildVersionI import { SettingsSection } from '../SettingsSection'; import { GeneralSettings } from '../../project-settings/GeneralSettings'; import { SecuritySettings } from '../../project-settings/SecuritySettings'; -import { LinearIntegration } from '../integrations/LinearIntegration'; -import { GitHubIntegration } from '../integrations/GitHubIntegration'; -import { GitLabIntegration } from '../integrations/GitLabIntegration'; +import { SourceControlSection } from '../integrations/SourceControlSection'; +import { IssueTrackingSection } from '../integrations/IssueTrackingSection'; +import { VaultIntegration } from '../integrations/VaultIntegration'; import { InitializationGuard } from '../common/InitializationGuard'; +import { useSettings } from '../hooks/useSettings'; import type { ProjectSettingsSection } from '../ProjectSettingsContent'; interface SectionRouterProps { @@ -41,7 +42,7 @@ interface SectionRouterProps { /** * Routes to the appropriate settings section based on activeSection. - * Handles initialization guards and section-specific configurations. + * Uses consolidated tabbed sections: Source Control, Issue Tracking, Memory & Context. */ export function SectionRouter({ activeSection, @@ -73,6 +74,7 @@ export function SectionRouter({ onOpenLinearImport }: SectionRouterProps) { const { t } = useTranslation('settings'); + const { settings: appSettings, setSettings: setAppSettings } = useSettings(); switch (activeSection) { case 'general': @@ -93,48 +95,28 @@ export function SectionRouter({ ); - case 'linear': + case 'source-control': return ( - - - - ); - - case 'github': - return ( - - - ); - case 'gitlab': + case 'issue-tracking': return ( - ); - case 'memory': + case 'memory-context': return ( - {}} - /> +
+ {}} + /> +
+

{t('vault.title')}

+ +
+
); diff --git a/apps/desktop/src/renderer/lib/browser-mock.ts b/apps/desktop/src/renderer/lib/browser-mock.ts index 5259afd86c..84ed738739 100644 --- a/apps/desktop/src/renderer/lib/browser-mock.ts +++ b/apps/desktop/src/renderer/lib/browser-mock.ts @@ -451,7 +451,30 @@ const browserMockAPI: ElectronAPI = { openLogsFolder: async () => ({ success: false, error: 'Not available in browser mode' }), copyDebugInfo: async () => ({ success: false, error: 'Not available in browser mode' }), getRecentErrors: async () => [], - listLogFiles: async () => [] + listLogFiles: async () => [], + + // JIRA Integration (mock) + jiraTestConnection: async () => ({ success: false, error: 'Not available in browser mode' }), + jiraListProjects: async () => ({ success: true, data: [] }), + jiraSearchIssues: async () => ({ success: true, data: { issues: [], total: 0 } }), + jiraGetIssue: async () => ({ success: false, error: 'Not available in browser mode' }), + jiraCreateIssue: async () => ({ success: false, error: 'Not available in browser mode' }), + jiraAddComment: async () => ({ success: false, error: 'Not available in browser mode' }), + jiraGetTransitions: async () => ({ success: true, data: [] }), + jiraTransitionIssue: async () => ({ success: false, error: 'Not available in browser mode' }), + jiraGetIssueComments: async () => ({ success: true, data: [] }), + investigateJiraIssue: () => {}, + onJiraInvestigationProgress: () => () => {}, + onJiraInvestigationComplete: () => () => {}, + onJiraInvestigationError: () => () => {}, + + // Vault Integration (mock) + vaultValidatePath: async () => ({ success: true, data: { valid: false, error: 'Not available in browser mode' } }), + vaultListFiles: async () => ({ success: true, data: [] }), + vaultReadFile: async () => ({ success: false, error: 'Not available in browser mode' }), + vaultSearch: async () => ({ success: true, data: [] }), + vaultGetContext: async () => ({ success: true, data: { learnings: [] } }), + vaultSaveLearning: async () => ({ success: false, error: 'Not available in browser mode' }) }; /** diff --git a/apps/desktop/src/renderer/stores/jira-store.ts b/apps/desktop/src/renderer/stores/jira-store.ts new file mode 100644 index 0000000000..4d2d4fd450 --- /dev/null +++ b/apps/desktop/src/renderer/stores/jira-store.ts @@ -0,0 +1,237 @@ +import { create } from 'zustand'; + +export interface JiraIssue { + key: string; + summary: string; + description?: string; + status: string; + assignee?: string; + priority?: string; + issueType: string; + created: string; + updated: string; + labels: string[]; +} + +export interface JiraInvestigationStatus { + phase: 'idle' | 'fetching' | 'analyzing' | 'creating' | 'complete' | 'error'; + issueKey?: string; + progress: number; + message: string; + error?: string; +} + +interface JiraState { + // Data + issues: JiraIssue[]; + connected: boolean; + + // UI State + isLoading: boolean; + error: string | null; + selectedIssueKey: string | null; + filterState: 'open' | 'closed' | 'all'; + searchQuery: string; + + // Investigation state + investigationStatus: JiraInvestigationStatus; + + // Actions + setIssues: (issues: JiraIssue[]) => void; + setConnected: (connected: boolean) => void; + setLoading: (loading: boolean) => void; + setError: (error: string | null) => void; + selectIssue: (key: string | null) => void; + setFilterState: (state: 'open' | 'closed' | 'all') => void; + setSearchQuery: (query: string) => void; + setInvestigationStatus: (status: JiraInvestigationStatus) => void; + clearIssues: () => void; + + // Selectors + getSelectedIssue: () => JiraIssue | null; + getFilteredIssues: () => JiraIssue[]; + getOpenIssuesCount: () => number; +} + +// Statuses considered "closed/done" - everything else is "open" +const CLOSED_STATUSES = ['done', 'closed', 'resolved', 'complete', 'completed', 'cancelled', 'won\'t do', 'declined']; + +function isOpenStatus(status: string): boolean { + // Anything NOT closed is open (avoids missing custom JIRA statuses) + return !CLOSED_STATUSES.includes(status.toLowerCase()); +} + +function isClosedStatus(status: string): boolean { + return CLOSED_STATUSES.includes(status.toLowerCase()); +} + +export const useJiraStore = create((set, get) => ({ + // Initial state + issues: [], + connected: false, + isLoading: false, + error: null, + selectedIssueKey: null, + filterState: 'open', + searchQuery: '', + investigationStatus: { + phase: 'idle', + progress: 0, + message: '' + }, + + // Actions + setIssues: (issues) => set({ issues, error: null }), + + setConnected: (connected) => set({ connected }), + + setLoading: (isLoading) => set({ isLoading }), + + setError: (error) => set({ error, isLoading: false }), + + selectIssue: (selectedIssueKey) => set({ selectedIssueKey }), + + setFilterState: (filterState) => set({ filterState }), + + setSearchQuery: (searchQuery) => set({ searchQuery }), + + setInvestigationStatus: (investigationStatus) => set({ investigationStatus }), + + clearIssues: () => set({ + issues: [], + connected: false, + selectedIssueKey: null, + error: null, + searchQuery: '', + investigationStatus: { phase: 'idle', progress: 0, message: '' } + }), + + // Selectors + getSelectedIssue: () => { + const { issues, selectedIssueKey } = get(); + return issues.find(i => i.key === selectedIssueKey) || null; + }, + + getFilteredIssues: () => { + const { issues, filterState, searchQuery } = get(); + let filtered = issues; + + // Filter by status + if (filterState === 'open') { + filtered = filtered.filter(issue => isOpenStatus(issue.status)); + } else if (filterState === 'closed') { + filtered = filtered.filter(issue => isClosedStatus(issue.status)); + } + + // Filter by search query + if (searchQuery.trim()) { + const query = searchQuery.toLowerCase(); + filtered = filtered.filter(issue => + issue.key.toLowerCase().includes(query) || + issue.summary.toLowerCase().includes(query) || + (issue.assignee && issue.assignee.toLowerCase().includes(query)) + ); + } + + return filtered; + }, + + getOpenIssuesCount: () => { + const { issues } = get(); + return issues.filter(issue => isOpenStatus(issue.status)).length; + } +})); + +/** + * Build JQL from the current filter state. + */ +function buildJql(projectKey: string, state: 'open' | 'closed' | 'all'): string { + let jql = `project = "${projectKey}"`; + if (state === 'open') { + jql += ' AND statusCategory != Done'; + } else if (state === 'closed') { + jql += ' AND statusCategory = Done'; + } + jql += ' ORDER BY updated DESC'; + return jql; +} + +/** + * Parse a raw JIRA issue response into our JiraIssue shape. + */ +function parseJiraIssue(raw: Record): JiraIssue { + // Backend already transforms the issue (flattened, no fields wrapper) + return { + key: (raw.key as string) || '', + summary: (raw.summary as string) || '', + description: typeof raw.description === 'string' ? raw.description : undefined, + status: (raw.status as string) || 'Unknown', + assignee: (raw.assignee as string) || undefined, + priority: (raw.priority as string) || undefined, + issueType: (raw.issueType as string) || 'Task', + created: (raw.created as string) || '', + updated: (raw.updated as string) || '', + labels: (raw.labels as string[]) || [] + }; +} + +// Action functions for use outside of React components +export async function loadJiraIssues(projectId: string, projectKey: string, state?: 'open' | 'closed' | 'all'): Promise { + const store = useJiraStore.getState(); + store.setLoading(true); + store.setError(null); + + if (state) { + store.setFilterState(state); + } + + const filterState = state || store.filterState; + const jql = buildJql(projectKey, filterState); + + try { + const result = await window.electronAPI.jiraSearchIssues(projectId, jql); + if (result.success && result.data) { + const issues = (result.data.issues || []).map(parseJiraIssue); + store.setIssues(issues); + } else { + store.setError(result.error || 'Failed to load JIRA issues'); + } + } catch (error) { + store.setError(error instanceof Error ? error.message : 'Unknown error'); + } finally { + store.setLoading(false); + } +} + +export async function checkJiraConnection(projectId: string): Promise { + const store = useJiraStore.getState(); + + try { + const result = await window.electronAPI.jiraTestConnection(projectId); + if (result.success) { + store.setConnected(true); + return true; + } else { + store.setConnected(false); + store.setError(result.error || 'Failed to connect to JIRA'); + return false; + } + } catch (error) { + store.setConnected(false); + store.setError(error instanceof Error ? error.message : 'Unknown error'); + return false; + } +} + +export function investigateJiraIssue(projectId: string, issueKey: string): void { + const store = useJiraStore.getState(); + store.setInvestigationStatus({ + phase: 'fetching', + issueKey, + progress: 0, + message: 'Starting investigation...' + }); + + // TODO: Call window.electronAPI.jiraInvestigateIssue(projectId, issueKey) when IPC is available + // For now, this is a placeholder. The InvestigationDialog handles the full flow. +} diff --git a/apps/desktop/src/shared/constants/ipc.ts b/apps/desktop/src/shared/constants/ipc.ts index 80e500b0e5..4f430b200b 100644 --- a/apps/desktop/src/shared/constants/ipc.ts +++ b/apps/desktop/src/shared/constants/ipc.ts @@ -247,6 +247,31 @@ export const IPC_CHANNELS = { IDEATION_TYPE_COMPLETE: 'ideation:typeComplete', IDEATION_TYPE_FAILED: 'ideation:typeFailed', + // JIRA integration + JIRA_TEST_CONNECTION: 'jira:testConnection', + JIRA_LIST_PROJECTS: 'jira:listProjects', + JIRA_SEARCH_ISSUES: 'jira:searchIssues', + JIRA_GET_ISSUE: 'jira:getIssue', + JIRA_CREATE_ISSUE: 'jira:createIssue', + JIRA_ADD_COMMENT: 'jira:addComment', + JIRA_GET_TRANSITIONS: 'jira:getTransitions', + JIRA_TRANSITION_ISSUE: 'jira:transitionIssue', + JIRA_GET_ISSUE_COMMENTS: 'jira:getIssueComments', + JIRA_INVESTIGATE_ISSUE: 'jira:investigateIssue', + + // JIRA events (main -> renderer) + JIRA_INVESTIGATION_PROGRESS: 'jira:investigationProgress', + JIRA_INVESTIGATION_COMPLETE: 'jira:investigationComplete', + JIRA_INVESTIGATION_ERROR: 'jira:investigationError', + + // Vault integration + VAULT_VALIDATE_PATH: 'vault:validatePath', + VAULT_LIST_FILES: 'vault:listFiles', + VAULT_READ_FILE: 'vault:readFile', + VAULT_SEARCH: 'vault:search', + VAULT_GET_CONTEXT: 'vault:getContext', + VAULT_SAVE_LEARNING: 'vault:saveLearning', + // Linear integration LINEAR_GET_TEAMS: 'linear:getTeams', LINEAR_GET_PROJECTS: 'linear:getProjects', diff --git a/apps/desktop/src/shared/i18n/index.ts b/apps/desktop/src/shared/i18n/index.ts index 095b0b1188..37f7f2fea1 100644 --- a/apps/desktop/src/shared/i18n/index.ts +++ b/apps/desktop/src/shared/i18n/index.ts @@ -13,6 +13,7 @@ import enGitlab from './locales/en/gitlab.json'; import enTaskReview from './locales/en/taskReview.json'; import enTerminal from './locales/en/terminal.json'; import enErrors from './locales/en/errors.json'; +import enJira from './locales/en/jira.json'; // Import French translation resources import frCommon from './locales/fr/common.json'; @@ -26,6 +27,7 @@ import frGitlab from './locales/fr/gitlab.json'; import frTaskReview from './locales/fr/taskReview.json'; import frTerminal from './locales/fr/terminal.json'; import frErrors from './locales/fr/errors.json'; +import frJira from './locales/fr/jira.json'; export const defaultNS = 'common'; @@ -41,7 +43,8 @@ export const resources = { gitlab: enGitlab, taskReview: enTaskReview, terminal: enTerminal, - errors: enErrors + errors: enErrors, + jira: enJira }, fr: { common: frCommon, @@ -54,7 +57,8 @@ export const resources = { gitlab: frGitlab, taskReview: frTaskReview, terminal: frTerminal, - errors: frErrors + errors: frErrors, + jira: frJira } } as const; @@ -65,7 +69,7 @@ i18n lng: 'en', // Default language (will be overridden by settings) fallbackLng: 'en', defaultNS, - ns: ['common', 'navigation', 'settings', 'tasks', 'welcome', 'onboarding', 'dialogs', 'gitlab', 'taskReview', 'terminal', 'errors'], + ns: ['common', 'navigation', 'settings', 'tasks', 'welcome', 'onboarding', 'dialogs', 'gitlab', 'taskReview', 'terminal', 'errors', 'jira'], interpolation: { escapeValue: false // React already escapes values }, diff --git a/apps/desktop/src/shared/i18n/locales/en/jira.json b/apps/desktop/src/shared/i18n/locales/en/jira.json new file mode 100644 index 0000000000..0ed751f0b5 --- /dev/null +++ b/apps/desktop/src/shared/i18n/locales/en/jira.json @@ -0,0 +1,64 @@ +{ + "title": "JIRA Issues", + "states": { + "open": "Open", + "closed": "Closed" + }, + "header": { + "open": "open", + "searchPlaceholder": "Search issues..." + }, + "filters": { + "open": "Open", + "closed": "Closed", + "all": "All" + }, + "empty": { + "noMatch": "No issues match your search", + "selectIssue": "Select an issue to view details" + }, + "notConnected": { + "title": "JIRA Not Connected", + "description": "Configure your JIRA host, email, and API token in project settings to sync issues.", + "openSettings": "Open Settings" + }, + "detail": { + "createTask": "Create Task", + "viewTask": "View Task", + "taskLinked": "Task Linked", + "taskId": "Task ID", + "description": "Description", + "noDescription": "No description provided.", + "assignee": "Assignee", + "priority": "Priority", + "status": "Status", + "type": "Type", + "labels": "Labels", + "created": "Created", + "updated": "Updated" + }, + "investigation": { + "title": "Create Task from Issue", + "issuePrefix": "Issue", + "description": "Create a task from this JIRA issue. The task will be added to your Kanban board in the Backlog column.", + "willInclude": "The task will include:", + "includeTitle": "Issue title and description", + "includeLink": "Link back to the JIRA issue", + "includeLabels": "Labels and metadata from the issue", + "taskCreated": "Task created! View it in your Kanban board.", + "creating": "Creating...", + "fetchingDetails": "Fetching issue details...", + "analyzingIssue": "Analyzing issue...", + "creatingTask": "Creating task...", + "cancel": "Cancel", + "done": "Done", + "close": "Close" + }, + "priority": { + "highest": "Highest", + "high": "High", + "medium": "Medium", + "low": "Low", + "lowest": "Lowest" + } +} diff --git a/apps/desktop/src/shared/i18n/locales/en/navigation.json b/apps/desktop/src/shared/i18n/locales/en/navigation.json index d19cabee01..97be7735dd 100644 --- a/apps/desktop/src/shared/i18n/locales/en/navigation.json +++ b/apps/desktop/src/shared/i18n/locales/en/navigation.json @@ -15,6 +15,7 @@ "githubPRs": "GitHub PRs", "gitlabIssues": "GitLab Issues", "gitlabMRs": "GitLab MRs", + "jiraIssues": "JIRA Issues", "worktrees": "Worktrees", "agentTools": "MCP Overview" }, diff --git a/apps/desktop/src/shared/i18n/locales/en/settings.json b/apps/desktop/src/shared/i18n/locales/en/settings.json index 68cfa87c67..7985c23ab8 100644 --- a/apps/desktop/src/shared/i18n/locales/en/settings.json +++ b/apps/desktop/src/shared/i18n/locales/en/settings.json @@ -41,6 +41,10 @@ "title": "Notifications", "description": "Alert preferences" }, + "vault": { + "title": "Vault", + "description": "External vault for agent context" + }, "debug": { "title": "Debug & Logs", "description": "Troubleshooting tools" @@ -361,6 +365,27 @@ "title": "Claude Auth", "description": "Claude authentication" }, + "source-control": { + "title": "Source Control", + "description": "GitHub & GitLab repositories", + "integrationTitle": "Source Control", + "integrationDescription": "Configure source control providers for repository management", + "syncDescription": "Connect to GitHub or GitLab" + }, + "issue-tracking": { + "title": "Issue Tracking", + "description": "Linear, JIRA & more", + "integrationTitle": "Issue Tracking", + "integrationDescription": "Configure issue tracking providers", + "syncDescription": "Connect to an issue tracker" + }, + "memory-context": { + "title": "Memory & Context", + "description": "Agent memory and vault", + "integrationTitle": "Memory & Context", + "integrationDescription": "Configure agent memory and external context sources", + "syncDescription": "Configure memory and context" + }, "linear": { "title": "Linear", "description": "Linear integration", @@ -394,6 +419,13 @@ "integrationDescription": "Connect to GitLab for issue tracking", "syncDescription": "Sync with GitLab Issues" }, + "jira": { + "title": "JIRA", + "description": "JIRA issue tracking", + "integrationTitle": "JIRA Integration", + "integrationDescription": "Connect to JIRA for issue tracking", + "syncDescription": "Sync with JIRA Issues" + }, "memory": { "title": "Memory", "description": "Graphiti memory backend", @@ -1184,5 +1216,50 @@ "description": "An error occurred while saving your provider configuration." } } + }, + "jira": { + "title": "JIRA Integration", + "description": "Connect to JIRA for issue tracking", + "enableIntegration": "Enable JIRA Integration", + "enableIntegrationDescription": "Connect to JIRA for issue tracking and task management", + "hostUrl": "JIRA Host URL", + "hostUrlDescription": "Your JIRA instance URL (Cloud or self-hosted)", + "email": "Email", + "emailDescription": "Your JIRA account email address", + "apiToken": "API Token", + "apiTokenDescription": "Generate a token from", + "atlassianSettings": "Atlassian API Token Settings", + "projectKey": "Default Project Key", + "projectKeyDescription": "The JIRA project key for creating and linking issues (e.g., PROJ, CAP)", + "testConnection": "Test Connection", + "testing": "Testing...", + "connected": "Connected", + "connectedAs": "Connected as", + "connectionFailed": "Connection failed", + "notConnected": "Not connected", + "connectionStatus": "Connection Status" + }, + "vault": { + "title": "Vault Integration", + "description": "Connect to an external vault for agent context", + "path": "Vault Path", + "pathPlaceholder": "/path/to/vault", + "pathDescription": "Absolute path to your vault directory", + "validate": "Validate", + "validatePath": "Validate", + "validating": "Validating...", + "pathValid": "Valid vault path", + "pathRequired": "Vault path is required", + "invalidPath": "Invalid vault path", + "validationFailed": "Validation failed", + "pathInvalid": "Invalid path: {{error}}", + "enabled": "Vault Integration Enabled", + "enabledDescription": "Load context from vault into agent sessions", + "syncLearnings": "Sync Learnings", + "syncLearningsDescription": "Save session learnings to vault", + "autoLoad": "Auto-load Context", + "autoLoadDescription": "Automatically inject vault CLAUDE.md into agent prompts", + "writeEnabled": "Allow Write Operations", + "writeEnabledDescription": "Allow the app to write files to the vault" } } diff --git a/apps/desktop/src/shared/i18n/locales/fr/jira.json b/apps/desktop/src/shared/i18n/locales/fr/jira.json new file mode 100644 index 0000000000..a51ba36f46 --- /dev/null +++ b/apps/desktop/src/shared/i18n/locales/fr/jira.json @@ -0,0 +1,64 @@ +{ + "title": "Tickets JIRA", + "states": { + "open": "Ouvert", + "closed": "Ferme" + }, + "header": { + "open": "ouvert", + "searchPlaceholder": "Rechercher des tickets..." + }, + "filters": { + "open": "Ouvert", + "closed": "Ferme", + "all": "Tous" + }, + "empty": { + "noMatch": "Aucun ticket ne correspond a votre recherche", + "selectIssue": "Selectionnez un ticket pour voir les details" + }, + "notConnected": { + "title": "JIRA non connecte", + "description": "Configurez votre hote JIRA, email et jeton API dans les parametres du projet pour synchroniser les tickets.", + "openSettings": "Ouvrir les parametres" + }, + "detail": { + "createTask": "Creer une tache", + "viewTask": "Voir la tache", + "taskLinked": "Tache liee", + "taskId": "ID de tache", + "description": "Description", + "noDescription": "Aucune description fournie.", + "assignee": "Responsable", + "priority": "Priorite", + "status": "Statut", + "type": "Type", + "labels": "Labels", + "created": "Cree le", + "updated": "Mis a jour le" + }, + "investigation": { + "title": "Creer une tache a partir du ticket", + "issuePrefix": "Ticket", + "description": "Creer une tache a partir de ce ticket JIRA. La tache sera ajoutee a votre tableau Kanban dans la colonne Backlog.", + "willInclude": "La tache inclura :", + "includeTitle": "Titre et description du ticket", + "includeLink": "Lien vers le ticket JIRA", + "includeLabels": "Labels et metadonnees du ticket", + "taskCreated": "Tache creee ! Consultez-la dans votre tableau Kanban.", + "creating": "Creation...", + "fetchingDetails": "Recuperation des details du ticket...", + "analyzingIssue": "Analyse du ticket...", + "creatingTask": "Creation de la tache...", + "cancel": "Annuler", + "done": "Termine", + "close": "Fermer" + }, + "priority": { + "highest": "La plus haute", + "high": "Haute", + "medium": "Moyenne", + "low": "Basse", + "lowest": "La plus basse" + } +} diff --git a/apps/desktop/src/shared/i18n/locales/fr/navigation.json b/apps/desktop/src/shared/i18n/locales/fr/navigation.json index 06ac517360..6d33afdcf3 100644 --- a/apps/desktop/src/shared/i18n/locales/fr/navigation.json +++ b/apps/desktop/src/shared/i18n/locales/fr/navigation.json @@ -15,6 +15,7 @@ "githubPRs": "PRs GitHub", "gitlabIssues": "Issues GitLab", "gitlabMRs": "MRs GitLab", + "jiraIssues": "Tickets JIRA", "worktrees": "Worktrees", "agentTools": "Aperçu MCP" }, diff --git a/apps/desktop/src/shared/i18n/locales/fr/settings.json b/apps/desktop/src/shared/i18n/locales/fr/settings.json index 5970f01a76..c296e94096 100644 --- a/apps/desktop/src/shared/i18n/locales/fr/settings.json +++ b/apps/desktop/src/shared/i18n/locales/fr/settings.json @@ -41,6 +41,10 @@ "title": "Notifications", "description": "Préférences d'alertes" }, + "vault": { + "title": "Vault", + "description": "Vault externe pour le contexte agent" + }, "debug": { "title": "Debug & Logs", "description": "Outils de dépannage" @@ -361,6 +365,27 @@ "title": "Auth Claude", "description": "Authentification Claude" }, + "source-control": { + "title": "Contrôle de source", + "description": "Dépôts GitHub & GitLab", + "integrationTitle": "Contrôle de source", + "integrationDescription": "Configurer les fournisseurs de contrôle de source", + "syncDescription": "Se connecter à GitHub ou GitLab" + }, + "issue-tracking": { + "title": "Suivi des tickets", + "description": "Linear, JIRA et plus", + "integrationTitle": "Suivi des tickets", + "integrationDescription": "Configurer les fournisseurs de suivi des tickets", + "syncDescription": "Se connecter à un outil de suivi" + }, + "memory-context": { + "title": "Mémoire & Contexte", + "description": "Mémoire agent et vault", + "integrationTitle": "Mémoire & Contexte", + "integrationDescription": "Configurer la mémoire agent et les sources de contexte", + "syncDescription": "Configurer la mémoire et le contexte" + }, "linear": { "title": "Linear", "description": "Intégration Linear", @@ -394,6 +419,13 @@ "integrationDescription": "Se connecter à GitLab pour le suivi des issues", "syncDescription": "Synchroniser avec GitLab Issues" }, + "jira": { + "title": "JIRA", + "description": "Suivi des tickets JIRA", + "integrationTitle": "Intégration JIRA", + "integrationDescription": "Se connecter à JIRA pour le suivi des tickets", + "syncDescription": "Synchroniser avec JIRA" + }, "memory": { "title": "Mémoire", "description": "Backend mémoire Graphiti", @@ -1184,5 +1216,50 @@ "description": "Une erreur s'est produite lors de l'enregistrement de la configuration du fournisseur." } } + }, + "jira": { + "title": "Intégration JIRA", + "description": "Connecter JIRA pour le suivi des tickets", + "enableIntegration": "Activer l'intégration JIRA", + "enableIntegrationDescription": "Se connecter à JIRA pour le suivi et la gestion des tickets", + "hostUrl": "URL de l'hôte JIRA", + "hostUrlDescription": "URL de votre instance JIRA (Cloud ou auto-hébergé)", + "email": "Email", + "emailDescription": "Adresse email de votre compte JIRA", + "apiToken": "Jeton API", + "apiTokenDescription": "Générer un jeton depuis", + "atlassianSettings": "Paramètres des jetons API Atlassian", + "projectKey": "Clé de projet par défaut", + "projectKeyDescription": "La clé du projet JIRA pour créer et lier les tickets (ex: PROJ, CAP)", + "testConnection": "Tester la connexion", + "testing": "Test en cours...", + "connected": "Connecté", + "connectedAs": "Connecté en tant que", + "connectionFailed": "Échec de connexion", + "notConnected": "Non connecté", + "connectionStatus": "État de la connexion" + }, + "vault": { + "title": "Intégration Vault", + "description": "Connecter un vault externe pour le contexte agent", + "path": "Chemin du vault", + "pathPlaceholder": "/chemin/vers/vault", + "pathDescription": "Chemin absolu vers votre répertoire vault", + "validate": "Valider", + "validatePath": "Valider", + "validating": "Validation...", + "pathValid": "Chemin vault valide", + "pathRequired": "Le chemin du vault est requis", + "invalidPath": "Chemin vault invalide", + "validationFailed": "Échec de la validation", + "pathInvalid": "Chemin invalide : {{error}}", + "enabled": "Intégration Vault activée", + "enabledDescription": "Charger le contexte du vault dans les sessions agent", + "syncLearnings": "Synchroniser les apprentissages", + "syncLearningsDescription": "Sauvegarder les apprentissages de session dans le vault", + "autoLoad": "Chargement automatique du contexte", + "autoLoadDescription": "Injecter automatiquement le CLAUDE.md du vault dans les prompts agent", + "writeEnabled": "Autoriser les écritures", + "writeEnabledDescription": "Permettre à l'application d'écrire dans le vault" } } diff --git a/apps/desktop/src/shared/types/integrations.ts b/apps/desktop/src/shared/types/integrations.ts index 741e388f33..3f3bb2f1af 100644 --- a/apps/desktop/src/shared/types/integrations.ts +++ b/apps/desktop/src/shared/types/integrations.ts @@ -280,6 +280,40 @@ export interface GitLabInvestigationStatus { error?: string; } +// ============================================ +// JIRA Investigation Types +// ============================================ + +export interface JiraInvestigationResult { + success: boolean; + issueKey: string; + analysis: { + summary: string; + proposedSolution: string; + affectedFiles: string[]; + estimatedComplexity: 'trivial' | 'standard' | 'complex'; + acceptanceCriteria: string[]; + }; + taskId?: string; + error?: string; +} + +export interface JiraInvestigationStatus { + phase: 'idle' | 'fetching' | 'analyzing' | 'creating_task' | 'complete' | 'error'; + issueKey?: string; + progress: number; + message: string; + error?: string; +} + +export interface JiraComment { + id: string; + body: string; + author: { displayName: string; accountId: string }; + created: string; + updated: string; +} + // ============================================ // GitLab MR Review Types // ============================================ diff --git a/apps/desktop/src/shared/types/ipc.ts b/apps/desktop/src/shared/types/ipc.ts index eb99c71553..53f7f8affc 100644 --- a/apps/desktop/src/shared/types/ipc.ts +++ b/apps/desktop/src/shared/types/ipc.ts @@ -650,6 +650,29 @@ export interface ElectronAPI { callback: (projectId: string, error: string) => void ) => () => void; + // JIRA integration operations + jiraTestConnection: (projectId: string) => Promise>; + jiraListProjects: (projectId: string) => Promise>>; + jiraSearchIssues: (projectId: string, jql: string) => Promise>; total: number }>>; + jiraGetIssue: (projectId: string, issueKey: string) => Promise>>; + jiraCreateIssue: (projectId: string, fields: Record) => Promise>; + jiraAddComment: (projectId: string, issueKey: string, body: string) => Promise; + jiraGetTransitions: (projectId: string, issueKey: string) => Promise>>; + jiraTransitionIssue: (projectId: string, issueKey: string, transitionId: string) => Promise; + jiraGetIssueComments: (projectId: string, issueKey: string) => Promise>>>; + investigateJiraIssue: (projectId: string, issueKey: string, selectedCommentIds?: string[]) => void; + onJiraInvestigationProgress: (callback: (projectId: string, status: { phase: string; progress: number; message: string }) => void) => () => void; + onJiraInvestigationComplete: (callback: (projectId: string, result: { taskId: string; specId: string }) => void) => () => void; + onJiraInvestigationError: (callback: (projectId: string, error: string) => void) => () => void; + + // Vault integration operations + vaultValidatePath: (vaultPath: string) => Promise>; + vaultListFiles: (vaultPath: string, subdir?: string) => Promise>>; + vaultReadFile: (vaultPath: string, filePath: string) => Promise>; + vaultSearch: (vaultPath: string, query: string) => Promise>>; + vaultGetContext: (vaultPath: string) => Promise>; + vaultSaveLearning: (vaultPath: string, filename: string, content: string) => Promise; + // Release operations getReleaseableVersions: (projectId: string) => Promise>; runReleasePreflightCheck: (projectId: string, version: string) => Promise>; diff --git a/apps/desktop/src/shared/types/project.ts b/apps/desktop/src/shared/types/project.ts index f8afb6339a..a1d97cff0f 100644 --- a/apps/desktop/src/shared/types/project.ts +++ b/apps/desktop/src/shared/types/project.ts @@ -328,6 +328,21 @@ export interface ProjectEnvConfig { gitlabProject?: string; // Format: group/project or numeric ID gitlabAutoSync?: boolean; // Auto-sync issues on project load + // JIRA Integration + jiraEnabled?: boolean; + jiraHost?: string; // JIRA instance URL (e.g., https://company.atlassian.net) + jiraEmail?: string; // JIRA user email + jiraToken?: string; // JIRA API token + jiraProjectKey?: string; // JIRA project key (e.g., CAP) + + // Independent issue tracking toggles (separate from source control enabled flags) + githubIssuesEnabled?: boolean; // Use GitHub for issue tracking (independent of githubEnabled) + gitlabIssuesEnabled?: boolean; // Use GitLab for issue tracking (independent of gitlabEnabled) + + // Source Control / Issue Tracker provider preference + sourceControlProvider?: 'github' | 'gitlab'; + issueTrackerProvider?: 'jira' | 'linear' | 'gitlab' | 'github'; + // Git/Worktree Settings defaultBranch?: string; // Base branch for worktree creation (e.g., 'main', 'develop') @@ -357,6 +372,10 @@ export interface ProjectEnvConfig { electronEnabled?: boolean; /** Puppeteer browser automation (QA only) - default: false */ puppeteerEnabled?: boolean; + /** JIRA issue tracking - default: follows jiraEnabled */ + jiraMcpEnabled?: boolean; + /** Vault external context - default: follows app vaultEnabled */ + vaultMcpEnabled?: boolean; }; // Per-agent MCP overrides (add/remove MCPs from specific agents) diff --git a/apps/desktop/src/shared/types/settings.ts b/apps/desktop/src/shared/types/settings.ts index 0245f84e7d..8c7661ff42 100644 --- a/apps/desktop/src/shared/types/settings.ts +++ b/apps/desktop/src/shared/types/settings.ts @@ -358,6 +358,19 @@ export interface AppSettings { sidebarCollapsed?: boolean; // GPU acceleration for terminal rendering (WebGL) gpuAcceleration?: GpuAcceleration; + // Global JIRA settings (used as defaults for all projects) + globalJiraHost?: string; // JIRA instance URL (e.g., https://company.atlassian.net) + globalJiraEmail?: string; // JIRA user email + globalJiraToken?: string; // JIRA API token + globalJiraDefaultProject?: string; // Default JIRA project key (e.g., CAP) + // Issue Tracker preference + issueTrackerProvider?: 'gitlab' | 'jira' | 'github' | 'linear'; + // Vault integration settings + globalVaultPath?: string; // Path to external vault directory + vaultEnabled?: boolean; // Whether vault integration is enabled + vaultSyncLearnings?: boolean; // Whether to sync learnings to vault + vaultAutoLoad?: boolean; // Whether to auto-load vault context + vaultWriteEnabled?: boolean; // Whether write operations are allowed } // GPU acceleration mode for terminal WebGL rendering diff --git a/apps/desktop/src/shared/types/task.ts b/apps/desktop/src/shared/types/task.ts index 0b2f06d953..3d81d00ec3 100644 --- a/apps/desktop/src/shared/types/task.ts +++ b/apps/desktop/src/shared/types/task.ts @@ -180,7 +180,7 @@ export type TaskCategory = export interface TaskMetadata { // Origin tracking - sourceType?: 'ideation' | 'manual' | 'imported' | 'insights' | 'roadmap' | 'linear' | 'github' | 'gitlab'; + sourceType?: 'ideation' | 'manual' | 'imported' | 'insights' | 'roadmap' | 'linear' | 'github' | 'gitlab' | 'jira'; ideationType?: string; // e.g., 'code_improvements', 'security_hardening' ideaId?: string; // Reference to original idea if converted featureId?: string; // Reference to roadmap feature if from roadmap @@ -193,6 +193,8 @@ export interface TaskMetadata { githubBatchTheme?: string; // Theme/title of the GitHub issue batch gitlabIssueIid?: number; // Reference to GitLab issue IID if from GitLab gitlabUrl?: string; // GitLab issue URL + jiraIssueKey?: string; // Reference to JIRA issue key if from JIRA (e.g., 'CFM-123') + jiraUrl?: string; // JIRA issue URL // Classification category?: TaskCategory; diff --git a/package-lock.json b/package-lock.json index 329ca0f1c9..1d7d5a3709 100644 --- a/package-lock.json +++ b/package-lock.json @@ -559,6 +559,7 @@ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -1133,6 +1134,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -1176,6 +1178,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -1215,6 +1218,7 @@ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "license": "MIT", + "peer": true, "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -1644,7 +1648,6 @@ "dev": true, "license": "BSD-2-Clause", "optional": true, - "peer": true, "dependencies": { "cross-dirname": "^0.1.0", "debug": "^4.3.4", @@ -1666,7 +1669,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -1683,7 +1685,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "universalify": "^2.0.0" }, @@ -1698,7 +1699,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">= 10.0.0" } @@ -2865,6 +2865,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -2886,6 +2887,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.6.0.tgz", "integrity": "sha512-L8UyDwqpTcbkIK5cgwDRDYDoEhQoj8wp8BwsO19w3LB1Z41yEQm2VJyNfAi9DrLP/YTqXqWpKHyZfR9/tFYo1Q==", "license": "Apache-2.0", + "peer": true, "engines": { "node": "^18.19.0 || >=20.6.0" }, @@ -2898,6 +2900,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.6.0.tgz", "integrity": "sha512-HLM1v2cbZ4TgYN6KEOj+Bbj8rAKriOdkF9Ed3tG25FoprSiQl7kYc+RRT6fUZGOvx0oMi5U67GoFdT+XUn8zEg==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -3321,6 +3324,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.6.0.tgz", "integrity": "sha512-D4y/+OGe3JSuYUCBxtH5T9DSAWNcvCb/nQWIga8HNtXTVPQn59j0nTBAgaAXxUVBDl40mG3Tc76b46wPlZaiJQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.6.0", "@opentelemetry/semantic-conventions": "^1.29.0" @@ -3337,6 +3341,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.6.0.tgz", "integrity": "sha512-g/OZVkqlxllgFM7qMKqbPV9c1DUPhQ7d4n3pgZFcrnrNft9eJXZM2TNHTPYREJBrtNdRytYyvwjgL5geDKl3EQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.6.0", "@opentelemetry/resources": "2.6.0", @@ -3354,6 +3359,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.40.0.tgz", "integrity": "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=14" } @@ -5662,6 +5668,7 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -5970,6 +5977,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -5980,6 +5988,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -6338,6 +6347,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6368,6 +6378,7 @@ "resolved": "https://registry.npmjs.org/ai/-/ai-6.0.116.tgz", "integrity": "sha512-7yM+cTmyRLeNIXwt4Vj+mrrJgVQ9RMIW5WO0ydoLoYkewIvsMcvUmqS4j2RJTUXaF1HphwmSKUMQ/HypNRGOmA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@ai-sdk/gateway": "3.0.66", "@ai-sdk/provider": "3.0.8", @@ -6387,6 +6398,7 @@ "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -7004,6 +7016,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -7745,8 +7758,7 @@ "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", "dev": true, "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/cross-env": { "version": "10.1.0", @@ -8141,6 +8153,7 @@ "integrity": "sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "app-builder-lib": "26.8.1", "builder-util": "26.8.1", @@ -8303,6 +8316,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^24.9.0", @@ -8551,7 +8565,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", @@ -8572,7 +8585,6 @@ "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", @@ -8608,16 +8620,6 @@ "node": ">= 0.8" } }, - "node_modules/encoding": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", - "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "license": "MIT", - "optional": true, - "dependencies": { - "iconv-lite": "^0.6.2" - } - }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -9792,6 +9794,7 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.10.tgz", "integrity": "sha512-kyWP5PAiMooEvGrA9jcD3IXF7ATu8+o7B3KCbPXid5se52NPqnOpM/r9qeW2heMnOekF4kqR1fXJqCYeCLKrZg==", "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -9981,6 +9984,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.28.6" }, @@ -10373,6 +10377,7 @@ "integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@acemir/cssom": "^0.9.28", "@asamuzakjp/dom-selector": "^6.7.6", @@ -13013,6 +13018,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -13088,7 +13094,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "commander": "^9.4.0" }, @@ -13106,7 +13111,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": "^12.20.0 || >=14" } @@ -13313,6 +13317,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -13322,6 +13327,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -13742,7 +13748,6 @@ "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "glob": "^7.1.3" }, @@ -14491,7 +14496,8 @@ "version": "4.2.1", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz", "integrity": "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tapable": { "version": "2.3.0", @@ -14530,7 +14536,6 @@ "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" @@ -14594,7 +14599,6 @@ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "minimist": "^1.2.6" }, @@ -14866,6 +14870,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -15205,6 +15210,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -15797,6 +15803,7 @@ "integrity": "sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.1.0", "@vitest/mocker": "4.1.0", @@ -16251,6 +16258,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" }