feat: complete Phase 5 of service refactoring
- Marked tasks in `TODO.md` as completed for moving TFS and Jira services to the `integrations` directory and correcting imports across the codebase. - Updated imports in various action files, API routes, and components to reflect the new structure. - Removed obsolete `jira-advanced-filters.ts`, `jira-analytics.ts`, `jira-analytics-cache.ts`, `jira-anomaly-detection.ts`, `jira-scheduler.ts`, `jira.ts`, and `tfs.ts` files to streamline the codebase. - Added new tasks in `TODO.md` for future cleaning and organization of service imports.
This commit is contained in:
828
src/services/integrations/jira/jira.ts
Normal file
828
src/services/integrations/jira/jira.ts
Normal file
@@ -0,0 +1,828 @@
|
||||
/**
|
||||
* Service de gestion Jira Cloud
|
||||
* Intégration unidirectionnelle Jira → TowerControl
|
||||
*/
|
||||
|
||||
import { JiraTask } from '@/lib/types';
|
||||
import { prisma } from '../../core/database';
|
||||
import { parseDate, formatDateForDisplay } from '@/lib/date-utils';
|
||||
|
||||
export interface JiraConfig {
|
||||
enabled: boolean;
|
||||
baseUrl?: string;
|
||||
email?: string;
|
||||
apiToken?: string;
|
||||
projectKey?: string; // Clé du projet à surveiller pour les analytics d'équipe (ex: "MYTEAM")
|
||||
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
|
||||
}
|
||||
|
||||
// Type générique pour compatibilité avec d'autres services
|
||||
export interface SyncAction {
|
||||
type: 'created' | 'updated' | 'skipped' | 'deleted';
|
||||
itemId: string | number;
|
||||
title: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface SyncResult {
|
||||
success: boolean;
|
||||
totalItems: number;
|
||||
actions: SyncAction[];
|
||||
errors: string[];
|
||||
stats: {
|
||||
created: number;
|
||||
updated: number;
|
||||
skipped: number;
|
||||
deleted: number;
|
||||
};
|
||||
}
|
||||
|
||||
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 {
|
||||
readonly config: JiraConfig;
|
||||
|
||||
constructor(config: JiraConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Valide l'existence d'un projet Jira
|
||||
*/
|
||||
async validateProject(projectKey: string): Promise<{ exists: boolean; name?: string; error?: string }> {
|
||||
try {
|
||||
const response = await this.makeJiraRequestPrivate(`/rest/api/3/project/${projectKey}`);
|
||||
|
||||
if (response.status === 404) {
|
||||
return { exists: false, error: `Projet "${projectKey}" introuvable` };
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
return { exists: false, error: `Erreur API: ${response.status} - ${errorText}` };
|
||||
}
|
||||
|
||||
const project = await response.json();
|
||||
return {
|
||||
exists: true,
|
||||
name: project.name
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la validation du projet:', error);
|
||||
return {
|
||||
exists: false,
|
||||
error: error instanceof Error ? error.message : 'Erreur de connexion'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Teste la connexion à Jira
|
||||
*/
|
||||
async testConnection(): Promise<boolean> {
|
||||
try {
|
||||
const response = await this.makeJiraRequestPrivate('/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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Valide la configuration Jira
|
||||
*/
|
||||
async validateConfig(): Promise<{ valid: boolean; error?: string }> {
|
||||
if (!this.config.enabled) {
|
||||
return { valid: false, error: 'Jira désactivé' };
|
||||
}
|
||||
if (!this.config.baseUrl) {
|
||||
return { valid: false, error: 'URL de base Jira manquante' };
|
||||
}
|
||||
if (!this.config.email) {
|
||||
return { valid: false, error: 'Email Jira manquant' };
|
||||
}
|
||||
if (!this.config.apiToken) {
|
||||
return { valid: false, error: 'Token API Jira manquant' };
|
||||
}
|
||||
|
||||
// Tester la connexion pour validation complète
|
||||
const connectionOk = await this.testConnection();
|
||||
if (!connectionOk) {
|
||||
return { valid: false, error: 'Impossible de se connecter avec ces paramètres' };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 avec une requête JQL personnalisée avec pagination
|
||||
*/
|
||||
async searchIssues(jql: string): Promise<JiraTask[]> {
|
||||
try {
|
||||
const fields = ['id', 'key', 'summary', 'description', 'status', 'priority', 'assignee', 'project', 'issuetype', 'components', 'fixVersions', '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.makeJiraRequestPrivate('/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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les tickets assignés à l'utilisateur connecté
|
||||
*/
|
||||
async getAssignedIssues(): Promise<JiraTask[]> {
|
||||
const jql = 'assignee = currentUser() AND resolution = Unresolved AND issuetype != Epic ORDER BY updated DESC';
|
||||
return this.searchIssues(jql);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<SyncResult> {
|
||||
const result: SyncResult = {
|
||||
success: false,
|
||||
totalItems: 0,
|
||||
actions: [],
|
||||
errors: [],
|
||||
stats: { created: 0, updated: 0, skipped: 0, deleted: 0 }
|
||||
};
|
||||
|
||||
// Variables locales pour compatibilité avec l'ancien code
|
||||
let tasksDeleted = 0;
|
||||
const jiraActions: JiraSyncAction[] = [];
|
||||
|
||||
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.totalItems = 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);
|
||||
|
||||
// Convertir JiraSyncAction vers SyncAction
|
||||
const standardAction: SyncAction = {
|
||||
type: syncAction.type,
|
||||
itemId: syncAction.taskKey,
|
||||
title: syncAction.taskTitle,
|
||||
message: syncAction.reason || syncAction.changes?.join('; ')
|
||||
};
|
||||
|
||||
// Ajouter l'action au résultat
|
||||
result.actions.push(standardAction);
|
||||
jiraActions.push(syncAction);
|
||||
|
||||
// Compter les actions
|
||||
if (syncAction.type === 'created') {
|
||||
result.stats.created++;
|
||||
} else if (syncAction.type === 'updated') {
|
||||
result.stats.updated++;
|
||||
} else {
|
||||
result.stats.skipped++;
|
||||
}
|
||||
} 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);
|
||||
tasksDeleted = deletedActions.length;
|
||||
result.stats.deleted = tasksDeleted;
|
||||
|
||||
// Convertir les actions de suppression
|
||||
for (const action of deletedActions) {
|
||||
const standardAction: SyncAction = {
|
||||
type: 'deleted',
|
||||
itemId: action.taskKey,
|
||||
title: action.taskTitle,
|
||||
message: action.reason
|
||||
};
|
||||
result.actions.push(standardAction);
|
||||
}
|
||||
|
||||
// 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<JiraSyncAction> {
|
||||
// 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 ? parseDate(jiraTask.duedate) : null,
|
||||
jiraProject: jiraTask.project.key,
|
||||
jiraKey: jiraTask.key,
|
||||
jiraType: this.mapJiraTypeToDisplay(jiraTask.issuetype.name),
|
||||
assignee: jiraTask.assignee?.displayName || null,
|
||||
updatedAt: parseDate(jiraTask.updated)
|
||||
};
|
||||
|
||||
if (!existingTask) {
|
||||
// Créer nouvelle tâche avec le tag Jira
|
||||
const newTask = await prisma.task.create({
|
||||
data: {
|
||||
...taskData,
|
||||
createdAt: parseDate(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 ? formatDateForDisplay(existingTask.dueDate) : 'Aucune';
|
||||
const newDate = taskData.dueDate ? formatDateForDisplay(taskData.dueDate) : '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<string>): Promise<JiraSyncAction[]> {
|
||||
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<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',
|
||||
'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<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': 'urgent',
|
||||
'High': 'high',
|
||||
'Medium': 'medium',
|
||||
'Low': 'low',
|
||||
'Lowest': 'low'
|
||||
};
|
||||
|
||||
return priorityMapping[jiraPriority] || 'medium';
|
||||
}
|
||||
|
||||
/**
|
||||
* Effectue une requête à l'API Jira avec authentification (méthode publique pour analytics)
|
||||
*/
|
||||
async makeJiraRequest(endpoint: string, method: string = 'GET', body?: unknown): Promise<Response> {
|
||||
return this.makeJiraRequestPrivate(endpoint, method, body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Effectue une requête à l'API Jira avec authentification
|
||||
*/
|
||||
private async makeJiraRequestPrivate(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: SyncResult): 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.stats.created + result.stats.updated
|
||||
}
|
||||
});
|
||||
} 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({
|
||||
enabled: true,
|
||||
baseUrl,
|
||||
email,
|
||||
apiToken
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user