Files
towercontrol/services/jira.ts
Julien Froidefond 7c139e4ce0 feat: enhance JiraService with task deletion logic
- Added `tasksDeleted` to `JiraSyncResult` to track deleted tasks.
- Implemented `cleanupUnassignedTasks` method to remove Jira tasks no longer assigned to the user, improving data accuracy and synchronization.
- Updated logging for better visibility during task cleanup process.
2025-09-17 15:55:40 +02:00

608 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;
tasksDeleted: number;
errors: string[];
}
export class JiraService {
private config: JiraConfig;
constructor(config: JiraConfig) {
this.config = config;
}
/**
* Teste la connexion à Jira
*/
async testConnection(): Promise<boolean> {
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<JiraTask[]> {
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<void> {
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<JiraSyncResult> {
const result: JiraSyncResult = {
success: false,
tasksFound: 0,
tasksCreated: 0,
tasksUpdated: 0,
tasksSkipped: 0,
tasksDeleted: 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 actuellement assignés
const jiraTasks = await this.getAssignedIssues();
result.tasksFound = jiraTasks.length;
console.log(`📋 ${jiraTasks.length} tickets trouvés dans Jira`);
// Récupérer la liste des IDs Jira actuels pour le nettoyage
const currentJiraIds = new Set(jiraTasks.map(task => task.id));
// 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'}`);
}
}
// Nettoyer les tâches Jira qui ne sont plus assignées à l'utilisateur
result.tasksDeleted = await this.cleanupUnassignedTasks(currentJiraIds);
// 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 ||
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,
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';
}
}
/**
* Nettoie les tâches Jira qui ne sont plus assignées à l'utilisateur
*/
private async cleanupUnassignedTasks(currentJiraIds: Set<string>): Promise<number> {
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
}
});
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 0;
}
console.log(`🗑️ ${tasksToDelete.length} tâche(s) à supprimer (plus assignées à l'utilisateur)`);
let deletedCount = 0;
// 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)`);
deletedCount++;
} catch (error) {
console.error(`❌ Erreur suppression tâche ${task.jiraKey}:`, error);
}
}
console.log(`✅ Nettoyage terminé: ${deletedCount} tâche(s) supprimée(s)`);
return deletedCount;
} 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 0;
}
}
/**
* Assigne le tag "🔗 From Jira" à une tâche si pas déjà assigné
*/
private async assignJiraTag(taskId: string): Promise<void> {
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<string, string> = {
// 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<string, string> = {
'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<string, string> = {
'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<Response> {
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<void> {
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 });
}