/** * 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; ignoredProjects?: string[]; // Liste des clés de projets à ignorer (ex: ["DEMO", "TEST"]) } export interface JiraSyncAction { type: 'created' | 'updated' | 'skipped' | 'deleted'; taskKey: string; taskTitle: string; reason?: string; // Raison du skip ou de la suppression changes?: string[]; // Liste des champs modifiés pour les updates } export interface JiraSyncResult { success: boolean; tasksFound: number; tasksCreated: number; tasksUpdated: number; tasksSkipped: number; tasksDeleted: number; errors: string[]; actions: JiraSyncAction[]; // Détail des actions effectuées } 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; } } /** * Filtre les tâches Jira selon les projets ignorés */ private filterIgnoredProjects(jiraTasks: JiraTask[]): JiraTask[] { if (!this.config.ignoredProjects || this.config.ignoredProjects.length === 0) { return jiraTasks; } const ignoredSet = new Set(this.config.ignoredProjects.map(p => p.toUpperCase())); return jiraTasks.filter(task => { const projectKey = task.project.key.toUpperCase(); const shouldIgnore = ignoredSet.has(projectKey); if (shouldIgnore) { console.log(`🚫 Ticket ${task.key} ignoré (projet ${task.project.key} dans la liste d'exclusion)`); } return !shouldIgnore; }); } /** * 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 nextPageToken: string | undefined = undefined; let pageNumber = 1; console.log('🔄 Récupération paginée des tickets Jira (POST /search/jql avec tokens)...'); while (true) { console.log(`📄 Page ${pageNumber} ${nextPageToken ? `(token présent)` : '(première page)'}`); // Utiliser POST /rest/api/3/search/jql avec nextPageToken selon la doc officielle const requestBody: { jql: string; fields: string[]; maxResults: number; nextPageToken?: string; } = { jql, fields, maxResults: 50 }; if (nextPageToken) { requestBody.nextPageToken = nextPageToken; } console.log(`🌐 POST /rest/api/3/search/jql avec ${nextPageToken ? 'nextPageToken' : 'première page'}`); const response = await this.makeJiraRequest('/rest/api/3/search/jql', 'POST', requestBody); console.log(`📡 Status réponse: ${response.status}`); 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[], nextPageToken?: string, isLast?: boolean }; 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 accumulé: ${allIssues.length})`); console.log(`🔍 Pagination info:`, { issuesLength: data.issues.length, hasNextPageToken: !!data.nextPageToken, isLast: data.isLast, pageNumber }); // Vérifier s'il y a plus de pages selon la doc officielle if (data.isLast === true || !data.nextPageToken) { console.log('🏁 Dernière page atteinte (isLast=true ou pas de nextPageToken)'); break; } nextPageToken = data.nextPageToken; pageNumber++; // Sécurité: éviter les boucles infinies if (allIssues.length >= 10000) { console.warn(`⚠️ Limite de sécurité atteinte (${allIssues.length} 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 } } /** * Synchronise les tickets Jira avec la base locale */ async syncTasks(): Promise { const result: JiraSyncResult = { success: false, tasksFound: 0, tasksCreated: 0, tasksUpdated: 0, tasksSkipped: 0, tasksDeleted: 0, errors: [], actions: [] }; try { console.log('🔄 Début de la synchronisation Jira...'); // S'assurer que le tag "From Jira" existe await this.ensureJiraTagExists(); // Récupérer les tickets Jira actuellement assignés const jiraTasks = await this.getAssignedIssues(); result.tasksFound = jiraTasks.length; console.log(`📋 ${jiraTasks.length} tickets trouvés dans Jira`); // Filtrer les tâches selon les projets ignorés const filteredTasks = this.filterIgnoredProjects(jiraTasks); console.log(`🔽 ${filteredTasks.length} tickets après filtrage des projets ignorés (${jiraTasks.length - filteredTasks.length} ignorés)`); // Récupérer la liste des IDs Jira actuels pour le nettoyage (après filtrage) const currentJiraIds = new Set(filteredTasks.map(task => task.id)); // Synchroniser chaque ticket for (const jiraTask of filteredTasks) { try { const syncAction = await this.syncSingleTask(jiraTask); // Ajouter l'action au résultat result.actions.push(syncAction); // Compter les actions if (syncAction.type === 'created') { result.tasksCreated++; } else if (syncAction.type === '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'}`); } } // Nettoyer les tâches Jira qui ne sont plus assignées à l'utilisateur const deletedActions = await this.cleanupUnassignedTasks(currentJiraIds); result.tasksDeleted = deletedActions.length; result.actions.push(...deletedActions); // 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 { // 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 le tag Jira await this.assignJiraTag(newTask.id); console.log(`➕ Nouvelle tâche créée: ${jiraTask.key}`); return { type: 'created', taskKey: jiraTask.key, taskTitle: jiraTask.summary }; } else { // Toujours mettre à jour les données Jira (écrasement forcé) // Détecter les changements et créer la liste des modifications const changes: string[] = []; // Préserver le titre et la priorité si modifiés localement const finalTitle = existingTask.title !== taskData.title ? existingTask.title : taskData.title; const finalPriority = existingTask.priority !== taskData.priority ? existingTask.priority : taskData.priority; if (existingTask.title !== taskData.title) { changes.push(`Titre: préservé localement ("${existingTask.title}")`); } if (existingTask.description !== taskData.description) { changes.push(`Description modifiée`); } if (existingTask.status !== taskData.status) { changes.push(`Statut: ${existingTask.status} → ${taskData.status}`); } if (existingTask.priority !== taskData.priority) { changes.push(`Priorité: préservée localement (${existingTask.priority})`); } if ((existingTask.dueDate?.getTime() || null) !== (taskData.dueDate?.getTime() || null)) { const oldDate = existingTask.dueDate ? existingTask.dueDate.toLocaleDateString() : 'Aucune'; const newDate = taskData.dueDate ? taskData.dueDate.toLocaleDateString() : 'Aucune'; changes.push(`Échéance: ${oldDate} → ${newDate}`); } if (existingTask.jiraProject !== taskData.jiraProject) { changes.push(`Projet: ${existingTask.jiraProject} → ${taskData.jiraProject}`); } if (existingTask.jiraType !== taskData.jiraType) { changes.push(`Type: ${existingTask.jiraType} → ${taskData.jiraType}`); } if (existingTask.assignee !== taskData.assignee) { changes.push(`Assigné: ${existingTask.assignee} → ${taskData.assignee}`); } if (changes.length === 0) { console.log(`⏭️ Aucun changement pour ${jiraTask.key}, skip mise à jour`); // S'assurer que le tag Jira est assigné (pour les anciennes tâches) même en skip await this.assignJiraTag(existingTask.id); return { type: 'skipped', taskKey: jiraTask.key, taskTitle: jiraTask.summary, reason: 'Aucun changement détecté' }; } // Mettre à jour les champs Jira (titre et priorité préservés si modifiés) await prisma.task.update({ where: { id: existingTask.id }, data: { title: finalTitle, description: taskData.description, status: taskData.status, priority: finalPriority, dueDate: taskData.dueDate, jiraProject: taskData.jiraProject, jiraKey: taskData.jiraKey, jiraType: taskData.jiraType, assignee: taskData.assignee, updatedAt: taskData.updatedAt } }); // S'assurer que le tag Jira est assigné (pour les anciennes tâches) await this.assignJiraTag(existingTask.id); console.log(`🔄 Tâche mise à jour (titre/priorité préservés): ${jiraTask.key} (${changes.length} changement${changes.length > 1 ? 's' : ''})`); return { type: 'updated', taskKey: jiraTask.key, taskTitle: jiraTask.summary, changes }; } } /** * Nettoie les tâches Jira qui ne sont plus assignées à l'utilisateur */ private async cleanupUnassignedTasks(currentJiraIds: Set): Promise { try { console.log('🧹 Début du nettoyage des tâches non assignées...'); // Trouver toutes les tâches Jira existantes dans la base const existingJiraTasks = await prisma.task.findMany({ where: { source: 'jira' }, select: { id: true, sourceId: true, jiraKey: true, title: true } }); console.log(`📊 ${existingJiraTasks.length} tâches Jira trouvées en base`); // Identifier les tâches à supprimer (celles qui ne sont plus dans Jira) const tasksToDelete = existingJiraTasks.filter(task => task.sourceId && !currentJiraIds.has(task.sourceId) ); if (tasksToDelete.length === 0) { console.log('✅ Aucune tâche à supprimer'); return []; } console.log(`🗑️ ${tasksToDelete.length} tâche(s) à supprimer (plus assignées à l'utilisateur)`); const deletedActions: JiraSyncAction[] = []; // Supprimer les tâches une par une avec logging for (const task of tasksToDelete) { try { await prisma.task.delete({ where: { id: task.id } }); console.log(`🗑️ Tâche supprimée: ${task.jiraKey} (non assignée)`); deletedActions.push({ type: 'deleted', taskKey: task.jiraKey || 'UNKNOWN', taskTitle: task.title, reason: 'Plus assignée à l\'utilisateur actuel' }); } catch (error) { console.error(`❌ Erreur suppression tâche ${task.jiraKey}:`, error); } } console.log(`✅ Nettoyage terminé: ${deletedActions.length} tâche(s) supprimée(s)`); return deletedActions; } catch (error) { console.error('❌ Erreur lors du nettoyage des tâches non assignées:', error); // Ne pas faire échouer la sync pour un problème de nettoyage return []; } } /** * 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 } } /** * 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 "Backlog" (pas encore priorisés) 'Backlog': 'backlog', 'Product backlog': 'backlog', 'Product Discovery': 'backlog', // Phase de découverte // Statuts "To Do" (priorisés, prêts à développer) 'To Do': 'todo', 'Open': 'todo', 'Ouvert': 'todo', // Français 'Selected for Development': 'todo', 'A faire': 'todo', // Français // Statuts "In Progress" 'In Progress': 'in_progress', 'En cours': 'in_progress', // Français 'In Review': 'in_progress', 'Code Review': 'in_progress', 'Code review': 'in_progress', // Variante casse 'Testing': 'in_progress', 'Product Delivery': 'in_progress', // Livré en prod // Statuts "Done" 'Done': 'done', 'Closed': 'done', 'Resolved': 'done', 'Complete': 'done', // Statuts bloqués 'Validating': 'freeze', // Phase de validation 'Blocked': 'freeze', 'On Hold': 'freeze', 'En attente du support': 'freeze' // Français - bloqué en attente }; 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': 'urgent', '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 }); }