/** * Service de gestion Jira Cloud * Intégration unidirectionnelle Jira → TowerControl */ import { JiraTask } from '@/lib/types'; import { prisma } from './database'; export interface JiraConfig { baseUrl: string; email: string; apiToken: string; } export interface JiraSyncResult { success: boolean; tasksFound: number; tasksCreated: number; tasksUpdated: number; tasksSkipped: number; errors: string[]; } export class JiraService { private config: JiraConfig; constructor(config: JiraConfig) { this.config = config; } /** * Teste la connexion à Jira */ async testConnection(): Promise { try { const response = await this.makeJiraRequest('/rest/api/3/myself'); if (!response.ok) { console.error(`Test connexion Jira échoué: ${response.status} ${response.statusText}`); const errorText = await response.text(); console.error('Détails erreur:', errorText); } return response.ok; } catch (error) { console.error('Erreur de connexion Jira:', error); return false; } } /** * Récupère les tickets assignés à l'utilisateur connecté avec pagination */ async getAssignedIssues(): Promise { try { const jql = 'assignee = currentUser() AND resolution = Unresolved AND issuetype != Epic ORDER BY updated DESC'; const fields = ['id', 'key', 'summary', 'description', 'status', 'priority', 'assignee', 'project', 'issuetype', 'duedate', 'created', 'updated', 'labels']; const allIssues: unknown[] = []; let startAt = 0; const maxResults = 100; // Taille des pages let hasMorePages = true; console.log('🔄 Récupération paginée des tickets Jira...'); while (hasMorePages) { const requestBody = { jql, fields }; console.log(`📄 Page ${Math.floor(startAt / maxResults) + 1} (tickets ${startAt + 1}-${startAt + maxResults})`); const response = await this.makeJiraRequest( `/rest/api/3/search/jql?startAt=${startAt}&maxResults=${maxResults}`, 'POST', requestBody ); if (!response.ok) { const errorText = await response.text(); console.error(`Erreur API Jira détaillée:`, { status: response.status, statusText: response.statusText, url: response.url, errorBody: errorText }); throw new Error(`Erreur API Jira: ${response.status} ${response.statusText}. Détails: ${errorText.substring(0, 200)}`); } const data = await response.json() as { issues: unknown[], total: number, maxResults: number, startAt: number }; if (!data.issues || !Array.isArray(data.issues)) { console.error('❌ Format de données inattendu:', data); throw new Error('Format de données Jira inattendu: pas d\'array issues'); } allIssues.push(...data.issues); console.log(`✅ ${data.issues.length} tickets récupérés (total: ${allIssues.length})`); // Vérifier s'il y a plus de pages hasMorePages = data.issues.length === maxResults && allIssues.length < (data.total || Number.MAX_SAFE_INTEGER); startAt += maxResults; // Sécurité: éviter les boucles infinies if (allIssues.length > 5000) { console.warn('⚠️ Limite de sécurité atteinte (5000 tickets). Arrêt de la pagination.'); break; } } console.log(`🎯 Total final: ${allIssues.length} tickets Jira récupérés`); return allIssues.map((issue: unknown) => this.mapJiraIssueToTask(issue)); } catch (error) { console.error('Erreur lors de la récupération des tickets Jira:', error); throw error; } } /** * S'assure que le tag "🔗 From Jira" existe dans la base */ private async ensureJiraTagExists(): Promise { try { const tagName = '🔗 From Jira'; // Vérifier si le tag existe déjà const existingTag = await prisma.tag.findUnique({ where: { name: tagName } }); if (!existingTag) { // Créer le tag s'il n'existe pas await prisma.tag.create({ data: { name: tagName, color: '#0082C9', // Bleu Jira isPinned: false } }); console.log(`✅ Tag "${tagName}" créé automatiquement`); } } catch (error) { console.error('Erreur lors de la création du tag Jira:', error); // Ne pas faire échouer la sync pour un problème de tag } } /** * Nettoie les epics Jira de la base (ne doivent plus être synchronisés) */ async cleanupEpics(): Promise { try { console.log('🧹 Nettoyage des epics Jira...'); // D'abord, listons toutes les tâches Jira pour voir lesquelles sont des epics const allJiraTasks = await prisma.task.findMany({ where: { source: 'jira' } }); console.log(`🔍 ${allJiraTasks.length} tâches Jira trouvées:`); allJiraTasks.forEach(task => { // @ts-expect-error - jiraType existe mais n'est pas encore dans les types générés console.log(` - ${task.jiraKey}: "${task.title}" [${task.jiraType || 'N/A'}] (ID: ${task.id})`); }); // Trouver les tâches Jira qui sont des epics // Maintenant on peut utiliser le type Jira mappé directement ! const epicsToDelete = await prisma.task.findMany({ where: { source: 'jira', // @ts-expect-error - jiraType existe mais n'est pas encore dans les types générés jiraType: 'Epic' // Maintenant standardisé grâce au mapping } }); if (epicsToDelete.length > 0) { // Supprimer les relations de tags d'abord await prisma.taskTag.deleteMany({ where: { taskId: { in: epicsToDelete.map(task => task.id) } } }); // Supprimer les tâches epics const result = await prisma.task.deleteMany({ where: { id: { in: epicsToDelete.map(task => task.id) } } }); console.log(`✅ ${result.count} epics supprimés de la base`); return result.count; } else { console.log('✅ Aucun epic trouvé à nettoyer'); return 0; } } catch (error) { console.error('Erreur lors du nettoyage des epics:', error); throw error; } } /** * Synchronise les tickets Jira avec la base locale */ async syncTasks(): Promise { const result: JiraSyncResult = { success: false, tasksFound: 0, tasksCreated: 0, tasksUpdated: 0, tasksSkipped: 0, errors: [] }; try { console.log('🔄 Début de la synchronisation Jira...'); // Nettoyer les epics existants (une seule fois) await this.cleanupEpics(); // S'assurer que le tag "From Jira" existe await this.ensureJiraTagExists(); // Récupérer les tickets Jira const jiraTasks = await this.getAssignedIssues(); result.tasksFound = jiraTasks.length; console.log(`📋 ${jiraTasks.length} tickets trouvés dans Jira`); // Synchroniser chaque ticket for (const jiraTask of jiraTasks) { try { const syncResult = await this.syncSingleTask(jiraTask); if (syncResult === 'created') { result.tasksCreated++; } else if (syncResult === 'updated') { result.tasksUpdated++; } else { result.tasksSkipped++; } } catch (error) { console.error(`Erreur sync ticket ${jiraTask.key}:`, error); result.errors.push(`${jiraTask.key}: ${error instanceof Error ? error.message : 'Erreur inconnue'}`); } } // Déterminer le succès et enregistrer le log result.success = result.errors.length === 0; await this.logSync(result); console.log('✅ Synchronisation Jira terminée:', result); return result; } catch (error) { console.error('❌ Erreur générale de synchronisation:', error); result.errors.push(error instanceof Error ? error.message : 'Erreur inconnue'); result.success = false; await this.logSync(result); return result; } } /** * Synchronise un ticket Jira unique */ private async syncSingleTask(jiraTask: JiraTask): Promise<'created' | 'updated' | 'skipped'> { // Chercher la tâche existante const existingTask = await prisma.task.findUnique({ where: { source_sourceId: { source: 'jira', sourceId: jiraTask.id } } }); const taskData = { title: jiraTask.summary, description: jiraTask.description || null, status: this.mapJiraStatusToInternal(jiraTask.status.name), priority: this.mapJiraPriorityToInternal(jiraTask.priority?.name), source: 'jira' as const, sourceId: jiraTask.id, dueDate: jiraTask.duedate ? new Date(jiraTask.duedate) : null, jiraProject: jiraTask.project.key, jiraKey: jiraTask.key, jiraType: this.mapJiraTypeToDisplay(jiraTask.issuetype.name), assignee: jiraTask.assignee?.displayName || null, updatedAt: new Date(jiraTask.updated) }; if (!existingTask) { // Créer nouvelle tâche avec le tag Jira const newTask = await prisma.task.create({ data: { ...taskData, createdAt: new Date(jiraTask.created) } }); // Assigner les tags Jira await this.assignJiraTag(newTask.id); await this.assignProjectTag(newTask.id, jiraTask.project.key); console.log(`➕ Nouvelle tâche créée: ${jiraTask.key}`); return 'created'; } else { // Vérifier si mise à jour nécessaire (seulement si pas de modifs locales récentes) const jiraUpdated = new Date(jiraTask.updated); const localUpdated = existingTask.updatedAt; // Si la tâche locale a été modifiée après la dernière update Jira, on skip if (localUpdated > jiraUpdated) { console.log(`⏭️ Tâche ${jiraTask.key} modifiée localement, skip mise à jour`); return 'skipped'; } // Mettre à jour seulement les champs Jira (pas les modifs locales) await prisma.task.update({ where: { id: existingTask.id }, data: { title: taskData.title, description: taskData.description, status: taskData.status, priority: taskData.priority, dueDate: taskData.dueDate, jiraProject: taskData.jiraProject, jiraKey: taskData.jiraKey, // @ts-expect-error - jiraType existe mais n'est pas encore dans les types générés jiraType: taskData.jiraType, assignee: taskData.assignee, updatedAt: taskData.updatedAt } }); // S'assurer que les tags Jira sont assignés (pour les anciennes tâches) await this.assignJiraTag(existingTask.id); await this.assignProjectTag(existingTask.id, jiraTask.project.key); console.log(`🔄 Tâche mise à jour: ${jiraTask.key}`); return 'updated'; } } /** * Assigne le tag "🔗 From Jira" à une tâche si pas déjà assigné */ private async assignJiraTag(taskId: string): Promise { try { const tagName = '🔗 From Jira'; // Récupérer le tag const jiraTag = await prisma.tag.findUnique({ where: { name: tagName } }); if (!jiraTag) { console.warn(`⚠️ Tag "${tagName}" introuvable lors de l'assignation`); return; } // Vérifier si le tag est déjà assigné const existingAssignment = await prisma.taskTag.findUnique({ where: { taskId_tagId: { taskId: taskId, tagId: jiraTag.id } } }); if (!existingAssignment) { // Créer l'assignation du tag await prisma.taskTag.create({ data: { taskId: taskId, tagId: jiraTag.id } }); console.log(`🏷️ Tag "${tagName}" assigné à la tâche`); } } catch (error) { console.error('Erreur lors de l\'assignation du tag Jira:', error); // Ne pas faire échouer la sync pour un problème de tag } } /** * Assigne un tag de projet Jira à une tâche (crée le tag si nécessaire) */ private async assignProjectTag(taskId: string, projectKey: string): Promise { try { const tagName = `📋 ${projectKey}`; // Vérifier si le tag projet existe déjà let projectTag = await prisma.tag.findUnique({ where: { name: tagName } }); if (!projectTag) { // Créer le tag projet s'il n'existe pas projectTag = await prisma.tag.create({ data: { name: tagName, color: '#4F46E5', // Violet pour les projets isPinned: false } }); console.log(`✅ Tag projet "${tagName}" créé automatiquement`); } // Vérifier si le tag est déjà assigné const existingAssignment = await prisma.taskTag.findUnique({ where: { taskId_tagId: { taskId: taskId, tagId: projectTag.id } } }); if (!existingAssignment) { // Créer l'assignation du tag await prisma.taskTag.create({ data: { taskId: taskId, tagId: projectTag.id } }); console.log(`🏷️ Tag projet "${tagName}" assigné à la tâche`); } } catch (error) { console.error('Erreur lors de l\'assignation du tag projet:', error); // Ne pas faire échouer la sync pour un problème de tag } } /** * Mappe un issue Jira vers le format JiraTask */ private mapJiraIssueToTask(issue: unknown): JiraTask { const issueData = issue as { id: string; key: string; fields: { summary: string; description?: { content?: { content?: { text: string }[] }[] }; status: { name: string; statusCategory: { name: string } }; priority?: { name: string }; assignee?: { displayName: string; emailAddress: string }; project: { key: string; name: string }; issuetype: { name: string }; duedate?: string; created: string; updated: string; labels?: string[]; }; }; return { id: issueData.id, key: issueData.key, summary: issueData.fields.summary, description: issueData.fields.description?.content?.[0]?.content?.[0]?.text || undefined, status: { name: issueData.fields.status.name, category: issueData.fields.status.statusCategory.name }, priority: issueData.fields.priority ? { name: issueData.fields.priority.name } : undefined, assignee: issueData.fields.assignee ? { displayName: issueData.fields.assignee.displayName, emailAddress: issueData.fields.assignee.emailAddress } : undefined, project: { key: issueData.fields.project.key, name: issueData.fields.project.name }, issuetype: { name: issueData.fields.issuetype.name }, duedate: issueData.fields.duedate, created: issueData.fields.created, updated: issueData.fields.updated, labels: issueData.fields.labels || [] }; } /** * Mappe les statuts Jira vers les statuts internes */ private mapJiraStatusToInternal(jiraStatus: string): string { const statusMapping: Record = { // Statuts "To Do" 'To Do': 'todo', 'Open': 'todo', 'Backlog': 'todo', 'Selected for Development': 'todo', // Statuts "In Progress" 'In Progress': 'in_progress', 'In Review': 'in_progress', 'Code Review': 'in_progress', 'Testing': 'in_progress', // Statuts "Done" 'Done': 'done', 'Closed': 'done', 'Resolved': 'done', 'Complete': 'done', // Statuts bloqués 'Blocked': 'blocked', 'On Hold': 'blocked' }; return statusMapping[jiraStatus] || 'todo'; } /** * Mappe les types Jira vers des termes plus courts */ private mapJiraTypeToDisplay(jiraType: string): string { const typeMap: Record = { 'Nouvelle fonctionnalité': 'Feature', 'Nouvelle Fonctionnalité': 'Feature', 'Feature': 'Feature', 'Story': 'Story', 'User Story': 'Story', 'Tâche': 'Task', 'Task': 'Task', 'Bug': 'Bug', 'Défaut': 'Bug', 'Support': 'Support', 'Enabler': 'Enabler', 'Epic': 'Epic', 'Épique': 'Epic' }; return typeMap[jiraType] || jiraType; } /** * Mappe les priorités Jira vers les priorités internes */ private mapJiraPriorityToInternal(jiraPriority?: string): string { if (!jiraPriority) return 'medium'; const priorityMapping: Record = { 'Highest': 'critical', 'High': 'high', 'Medium': 'medium', 'Low': 'low', 'Lowest': 'low' }; return priorityMapping[jiraPriority] || 'medium'; } /** * Effectue une requête à l'API Jira avec authentification */ private async makeJiraRequest(endpoint: string, method: string = 'GET', body?: unknown): Promise { const url = `${this.config.baseUrl}${endpoint}`; const auth = Buffer.from(`${this.config.email}:${this.config.apiToken}`).toString('base64'); const options: RequestInit = { method, headers: { 'Authorization': `Basic ${auth}`, 'Accept': 'application/json', 'Content-Type': 'application/json' } }; if (body && method !== 'GET') { options.body = JSON.stringify(body); } return fetch(url, options); } /** * Enregistre un log de synchronisation */ private async logSync(result: JiraSyncResult): Promise { try { await prisma.syncLog.create({ data: { source: 'jira', status: result.success ? 'success' : 'error', message: result.errors.length > 0 ? result.errors.join('; ') : null, tasksSync: result.tasksCreated + result.tasksUpdated } }); } catch (error) { console.error('Erreur lors de l\'enregistrement du log:', error); } } } /** * Factory pour créer une instance JiraService avec la config env */ export function createJiraService(): JiraService | null { const baseUrl = process.env.JIRA_BASE_URL; const email = process.env.JIRA_EMAIL; const apiToken = process.env.JIRA_API_TOKEN; if (!baseUrl || !email || !apiToken) { console.warn('Configuration Jira incomplète - service désactivé'); return null; } return new JiraService({ baseUrl, email, apiToken }); }