From b2a8c961a8d4435693820cd62c85126e18ea361b Mon Sep 17 00:00:00 2001 From: Julien Froidefond Date: Sat, 4 Oct 2025 11:49:41 +0200 Subject: [PATCH] feat: enhance Jira sync and update TODO.md - Added handling for unknown statuses in Jira sync, logging them for better debugging and mapping to "todo" by default. - Updated sync result structure to include unknown statuses and reflected this in the UI for visibility. - Adjusted JQL to include recently resolved tasks for better status updates during sync. - Marked the integration of unknown status handling as complete in TODO.md. --- TODO.md | 2 +- src/app/api/jira/sync/route.ts | 1 + src/components/jira/JiraSync.tsx | 21 +++- src/services/integrations/jira/jira.ts | 141 +++++++++++++++++++++---- src/services/integrations/tfs/tfs.ts | 18 +++- 5 files changed, 156 insertions(+), 27 deletions(-) diff --git a/TODO.md b/TODO.md index 458e4b9..e7cf9be 100644 --- a/TODO.md +++ b/TODO.md @@ -41,7 +41,7 @@ ### 🔧 Fonctionnalités et Intégrations - [ ] **Synchro Jira et TFS shortcuts** - Ajouter des raccourcis et bouton dans Kanban -- [x] **Intégration suppressions Jira/TFS** - Aligner la gestion des suppressions sur TFS, je veux que ce qu'on a récupéré dans la synchro, quand ca devient terminé dans Jira ou TFS, soit marqué comme terminé dans le Kanban et non supprimé du kanban. +- [x] **Intégration suppressions Jira/TFS** - Aligner la gestion des suppressions sur TFS, je veux que ce qu'on a récupéré dans la synchro, quand ca devient terminé dans Jira ou TFS, soit marqué comme terminé dans le Kanban et non supprimé du kanban. - [ ] **Log d'activité** - Implémenter un système de log d'activité (feature potentielle) --- diff --git a/src/app/api/jira/sync/route.ts b/src/app/api/jira/sync/route.ts index 79a4e2b..f6d2ee3 100644 --- a/src/app/api/jira/sync/route.ts +++ b/src/app/api/jira/sync/route.ts @@ -110,6 +110,7 @@ export async function POST(request: Request) { tasksSkipped: syncResult.stats.skipped, tasksDeleted: syncResult.stats.deleted, errors: syncResult.errors, + unknownStatuses: syncResult.unknownStatuses || [], // Nouveaux statuts inconnus actions: syncResult.actions.map(action => ({ type: action.type as 'created' | 'updated' | 'skipped' | 'deleted', taskKey: action.itemId.toString(), diff --git a/src/components/jira/JiraSync.tsx b/src/components/jira/JiraSync.tsx index cccece0..2d2b135 100644 --- a/src/components/jira/JiraSync.tsx +++ b/src/components/jira/JiraSync.tsx @@ -70,7 +70,7 @@ export function JiraSync({ onSyncComplete, className = "" }: JiraSyncProps) { const getSyncStatus = () => { if (!lastSyncResult) return null; - const { success, tasksFound, tasksCreated, tasksUpdated, tasksSkipped, tasksDeleted = 0, errors, actions = [] } = lastSyncResult; + const { success, tasksFound, tasksCreated, tasksUpdated, tasksSkipped, tasksDeleted = 0, errors, unknownStatuses = [], actions = [] } = lastSyncResult; return (
@@ -141,6 +141,25 @@ export function JiraSync({ onSyncComplete, className = "" }: JiraSyncProps) {
)} + + {unknownStatuses.length > 0 && ( +
+
+ ⚠️ Statuts inconnus ({unknownStatuses.length}) +
+
+ Ces statuts ont été mappés vers "todo" par défaut : +
+
+ {unknownStatuses.map((status, i) => ( +
+ → + "{status}" +
+ ))} +
+
+ )} ); }; diff --git a/src/services/integrations/jira/jira.ts b/src/services/integrations/jira/jira.ts index 58e8fff..85bac65 100644 --- a/src/services/integrations/jira/jira.ts +++ b/src/services/integrations/jira/jira.ts @@ -37,6 +37,7 @@ export interface SyncResult { totalItems: number; actions: SyncAction[]; errors: string[]; + unknownStatuses?: string[]; // Nouveaux statuts inconnus rencontrés stats: { created: number; updated: number; @@ -53,6 +54,7 @@ export interface JiraSyncResult { tasksSkipped: number; tasksDeleted: number; errors: string[]; + unknownStatuses?: string[]; // Statuts inconnus rencontrés actions: JiraSyncAction[]; // Détail des actions effectuées } @@ -255,9 +257,12 @@ export class JiraService { /** * Récupère les tickets assignés à l'utilisateur connecté + * Inclut les tickets non résolus ET les tickets récemment résolus (30 derniers jours) + * pour permettre la mise à jour du statut vers "done" lors de la synchro */ async getAssignedIssues(): Promise { - const jql = 'assignee = currentUser() AND resolution = Unresolved AND issuetype != Epic ORDER BY updated DESC'; + // Récupérer les tickets non résolus + résolus récemment (30 jours) + const jql = 'assignee = currentUser() AND (resolution = Unresolved OR resolved >= -30d) AND issuetype != Epic ORDER BY updated DESC'; return this.searchIssues(jql); } @@ -309,6 +314,9 @@ export class JiraService { try { console.log('🔄 Début de la synchronisation Jira...'); + // Réinitialiser la collecte des statuts inconnus + this.unknownStatuses.clear(); + // S'assurer que le tag "From Jira" existe await this.ensureJiraTagExists(); @@ -372,6 +380,11 @@ export class JiraService { result.actions.push(standardAction); } + // Ajouter les statuts inconnus au résultat + if (this.unknownStatuses.size > 0) { + result.unknownStatuses = Array.from(this.unknownStatuses); + } + // Déterminer le succès et enregistrer le log result.success = result.errors.length === 0; await this.logSync(result); @@ -402,6 +415,9 @@ export class JiraService { } }); + // Debug : Log du statut Jira pour comprendre le mapping + console.log(` 📝 Mapping statut Jira: "${jiraTask.status.name}" → ${this.mapJiraStatusToInternal(jiraTask.status.name)}`); + const taskData = { title: jiraTask.summary, description: jiraTask.description || null, @@ -418,18 +434,27 @@ export class JiraService { }; if (!existingTask) { + // Préparer les données de création + const createData: typeof taskData & { createdAt: Date; completedAt?: Date } = { + ...taskData, + createdAt: parseDate(jiraTask.created) + }; + + // Si la tâche est déjà terminée dans Jira, définir completedAt + if (taskData.status === 'done') { + createData.completedAt = new Date(); + console.log(` ✅ Nouvelle tâche déjà terminée dans Jira (completedAt défini)`); + } + // Créer nouvelle tâche avec le tag Jira const newTask = await prisma.task.create({ - data: { - ...taskData, - createdAt: parseDate(jiraTask.created) - } + data: createData }); // Assigner le tag Jira await this.assignJiraTag(newTask.id); - console.log(`➕ Nouvelle tâche créée: ${jiraTask.key}`); + console.log(`➕ Nouvelle tâche créée: ${jiraTask.key} (status: ${taskData.status})`); return { type: 'created', taskKey: jiraTask.key, @@ -486,21 +511,42 @@ export class JiraService { }; } + // Préparer les données de mise à jour + const updateData: { + title: string; + description: string | null; + status: string; + priority: string; + dueDate: Date | null; + jiraProject: string; + jiraKey: string; + jiraType: string; + assignee: string | null; + updatedAt: Date; + completedAt?: Date; + } = { + 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 + }; + + // Si la tâche passe à "done" et n'a pas encore de completedAt, le définir + if (taskData.status === 'done' && !existingTask.completedAt) { + updateData.completedAt = new Date(); + console.log(` ✅ Tâche marquée comme terminée (completedAt défini)`); + } + // 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 - } + data: updateData }); // S'assurer que le tag Jira est assigné (pour les anciennes tâches) @@ -518,6 +564,7 @@ export class JiraService { /** * Nettoie les tâches Jira qui ne sont plus assignées à l'utilisateur + * Ne supprime PAS les tâches déjà terminées (done/archived) pour garder l'historique */ private async cleanupUnassignedTasks(currentJiraIds: Set): Promise { try { @@ -532,16 +579,30 @@ export class JiraService { id: true, sourceId: true, jiraKey: true, - title: true + title: true, + status: 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) - ); + // MAIS on exclut les tâches déjà terminées (done/archived) car elles ont été résolues + const tasksToDelete = existingJiraTasks.filter(task => { + // Si la tâche est toujours dans Jira, on la garde + if (task.sourceId && currentJiraIds.has(task.sourceId)) { + return false; + } + + // Si la tâche est déjà terminée (done/archived), on la garde en base pour l'historique + if (task.status === 'done' || task.status === 'archived') { + console.log(`✅ Tâche ${task.jiraKey} terminée - conservée en base malgré absence dans Jira`); + return false; + } + + // Sinon, c'est une tâche non terminée qui n'est plus assignée → suppression + return task.sourceId && !currentJiraIds.has(task.sourceId); + }); if (tasksToDelete.length === 0) { console.log('✅ Aucune tâche à supprimer'); @@ -677,6 +738,11 @@ export class JiraService { }; } + /** + * Collecte des statuts inconnus pour le rapport + */ + private unknownStatuses = new Set(); + /** * Mappe les statuts Jira vers les statuts internes */ @@ -693,6 +759,7 @@ export class JiraService { 'Ouvert': 'todo', // Français 'Selected for Development': 'todo', 'A faire': 'todo', // Français + 'Ready To Activate': 'todo', // Prêt à activer // Statuts "In Progress" 'In Progress': 'in_progress', @@ -705,9 +772,29 @@ export class JiraService { // Statuts "Done" 'Done': 'done', + 'DONE': 'done', // Variante majuscule 'Closed': 'done', + 'CLOSED': 'done', // Variante majuscule 'Resolved': 'done', + 'RESOLVED': 'done', // Variante majuscule 'Complete': 'done', + 'COMPLETE': 'done', // Variante majuscule + 'Finished': 'done', // Ajout + 'FINISHED': 'done', // Ajout majuscule + 'Livré': 'done', // Français + 'LIVRÉ': 'done', // Français majuscule + 'Terminé': 'done', // Français + 'TERMINÉ': 'done', // Français majuscule + 'Fait': 'done', // Français + 'FAIT': 'done', // Français majuscule + 'Clôturé': 'done', // Français - terminé/fini + 'CLÔTURÉ': 'done', // Français majuscule + 'Cloturé': 'done', // Sans accent + 'CLOTURÉ': 'done', // Sans accent upper + 'Product Learning': 'done', // Phase d'apprentissage finale + 'PRODUCT LEARNING': 'done', // Majuscule + 'In Production': 'done', // Ajout + 'En Production': 'done', // Français // Statuts bloqués 'Validating': 'freeze', // Phase de validation @@ -716,7 +803,15 @@ export class JiraService { 'En attente du support': 'freeze' // Français - bloqué en attente }; - return statusMapping[jiraStatus] || 'todo'; + const mapped = statusMapping[jiraStatus] || 'todo'; + + // Collecter et log si statut inconnu pour debug + if (!statusMapping[jiraStatus]) { + this.unknownStatuses.add(jiraStatus); + console.log(`⚠️ Statut Jira inconnu: "${jiraStatus}" → mappé vers "todo" par défaut`); + } + + return mapped; } /** diff --git a/src/services/integrations/tfs/tfs.ts b/src/services/integrations/tfs/tfs.ts index 9870408..89b66bd 100644 --- a/src/services/integrations/tfs/tfs.ts +++ b/src/services/integrations/tfs/tfs.ts @@ -885,6 +885,7 @@ export class TfsService { /** * Nettoie les tâches TFS qui ne correspondent plus aux PRs actives + * Ne supprime PAS les tâches déjà terminées (done/archived) pour garder l'historique */ private async cleanupInactivePullRequests( currentPrIds: Set @@ -902,18 +903,31 @@ export class TfsService { sourceId: true, tfsPullRequestId: true, title: true, + status: true, }, }); // Identifier les tâches à supprimer + // MAIS on exclut les tâches déjà terminées (done/archived) car elles ont été complétées const tasksToDelete = existingTfsTasks.filter((task) => { const prId = task.tfsPullRequestId; if (!prId) { return true; } - const shouldKeep = currentPrIds.has(prId); - return !shouldKeep; + // Si la PR est toujours active, on la garde + if (currentPrIds.has(prId)) { + return false; + } + + // Si la tâche est déjà terminée (done/archived), on la garde en base pour l'historique + if (task.status === 'done' || task.status === 'archived') { + console.log(`✅ PR ${prId} terminée - conservée en base malgré absence dans TFS`); + return false; + } + + // Sinon, c'est une tâche non terminée qui n'est plus active → suppression + return true; }); // Supprimer les tâches obsolètes