/** * 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}/${data.total || '?'})`); // Vérifier s'il y a plus de pages hasMorePages = data.issues.length === maxResults && allIssues.length < (data.total || Number.MAX_SAFE_INTEGER); startAt += maxResults; console.log(`📊 Pagination: hasMorePages=${hasMorePages}, startAt=${startAt}, maxResults=${maxResults}`); // Sécurité: éviter les boucles infinies (augmenté avec la nouvelle taille de page) if (allIssues.length > 10000) { console.warn('⚠️ Limite de sécurité atteinte (10000 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, errors: [] }; 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 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 le tag Jira await this.assignJiraTag(newTask.id); 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'; } // Vérifier s'il y a vraiment des changements const hasChanges = existingTask.title !== taskData.title || existingTask.description !== taskData.description || existingTask.status !== taskData.status || existingTask.priority !== taskData.priority || (existingTask.dueDate?.getTime() || null) !== (taskData.dueDate?.getTime() || null) || existingTask.jiraProject !== taskData.jiraProject || existingTask.jiraKey !== taskData.jiraKey || // @ts-expect-error - jiraType existe mais n'est pas encore dans les types générés existingTask.jiraType !== taskData.jiraType || existingTask.assignee !== taskData.assignee; if (!hasChanges) { 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 '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 // Seulement si changements réels } }); // S'assurer que le tag Jira est assigné (pour les anciennes tâches) await this.assignJiraTag(existingTask.id); 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 } } /** * 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', 'Validating': 'in_progress', // Phase de validation // Statuts "Done" 'Done': 'done', 'Closed': 'done', 'Resolved': 'done', 'Complete': 'done', 'Product Delivery': 'done', // Livré en prod // Statuts bloqués 'Blocked': 'blocked', 'On Hold': 'blocked', 'En attente du support': 'blocked' // 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': '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 }); }