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:
Julien Froidefond
2025-09-23 10:32:25 +02:00
parent f5417040fd
commit 88ab8c9334
33 changed files with 46 additions and 44 deletions

View 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
});
}