feat(JiraSync): enhance synchronization logic to preserve original Jira actions and detect changes

- Updated the Jira synchronization process to include original Jira actions for better detail retention.
- Implemented a new function to detect real changes and preserved fields during task synchronization.
- Enhanced the UI to display actions with preserved fields separately for improved clarity.
- Added comprehensive tests for the new change detection logic to ensure accuracy and reliability.
This commit is contained in:
Julien Froidefond
2025-11-21 14:14:30 +01:00
parent d9e7a05f14
commit af41531597
6 changed files with 998 additions and 212 deletions

View File

@@ -118,6 +118,17 @@ export async function POST(request: Request) {
const syncResult = await jiraService.syncTasks(session.user.id); const syncResult = await jiraService.syncTasks(session.user.id);
// Convertir SyncResult en JiraSyncResult pour le client // Convertir SyncResult en JiraSyncResult pour le client
// Utiliser les actions Jira originales si disponibles pour préserver les détails (changes, etc.)
const actions =
syncResult.jiraActions ||
syncResult.actions.map((action) => ({
type: action.type as 'created' | 'updated' | 'skipped' | 'deleted',
taskKey: action.itemId.toString(),
taskTitle: action.title,
reason: action.message,
changes: action.message ? [action.message] : undefined,
}));
const jiraSyncResult = { const jiraSyncResult = {
success: syncResult.success, success: syncResult.success,
tasksFound: syncResult.totalItems, tasksFound: syncResult.totalItems,
@@ -127,13 +138,7 @@ export async function POST(request: Request) {
tasksDeleted: syncResult.stats.deleted, tasksDeleted: syncResult.stats.deleted,
errors: syncResult.errors, errors: syncResult.errors,
unknownStatuses: syncResult.unknownStatuses || [], // Nouveaux statuts inconnus unknownStatuses: syncResult.unknownStatuses || [], // Nouveaux statuts inconnus
actions: syncResult.actions.map((action) => ({ actions,
type: action.type as 'created' | 'updated' | 'skipped' | 'deleted',
taskKey: action.itemId.toString(),
taskTitle: action.title,
reason: action.message,
changes: action.message ? [action.message] : undefined,
})),
}; };
if (syncResult.success) { if (syncResult.success) {

View File

@@ -395,9 +395,157 @@ function SyncActionsList({ actions }: { actions: JiraSyncAction[] }) {
{} as Record<string, JiraSyncAction[]> {} as Record<string, JiraSyncAction[]>
); );
// Ordre d'affichage : créées, mises à jour, supprimées, ignorées (toujours en dernier)
const displayOrder: JiraSyncAction['type'][] = [
'created',
'updated',
'deleted',
'skipped',
];
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{Object.entries(groupedActions).map(([type, typeActions]) => ( {displayOrder
.filter((type) => groupedActions[type]?.length > 0)
.map((type) => {
const typeActions = groupedActions[type];
// Pour les actions skipped, séparer celles avec champs préservés de celles sans changement
if (type === 'skipped') {
const withPreservedFields = typeActions.filter(
(action) => action.changes && action.changes.length > 0
);
const withoutChanges = typeActions.filter(
(action) => !action.changes || action.changes.length === 0
);
return (
<div key={type} className="space-y-4">
<h4
className={`font-bold text-sm flex items-center gap-2 ${getActionColor(type as JiraSyncAction['type'])}`}
>
{getActionIcon(type as JiraSyncAction['type'])}
{getActionLabel(type as JiraSyncAction['type'])} (
{typeActions.length})
</h4>
{/* Tâches avec champs préservés */}
{withPreservedFields.length > 0 && (
<div className="space-y-3">
<div className="text-xs font-semibold text-[var(--muted-foreground)] uppercase tracking-wide flex items-center gap-2">
<div className="h-px flex-1 bg-[var(--border)]"></div>
<span className="text-orange-400">
Avec champs préservés ({withPreservedFields.length})
</span>
<div className="h-px flex-1 bg-[var(--border)]"></div>
</div>
<div className="space-y-2">
{withPreservedFields.map((action, index) => (
<div
key={index}
className="p-2 bg-[var(--muted)]/10 rounded border border-orange-400/30"
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-baseline gap-2">
<span className="font-mono text-sm font-bold text-[var(--foreground)] shrink-0">
{action.taskKey}
</span>
<span className="text-sm text-[var(--muted-foreground)] truncate">
{action.taskTitle}
</span>
</div>
</div>
<Badge
variant="outline"
size="sm"
className="shrink-0"
>
{getActionLabel(action.type)}
</Badge>
</div>
{action.reason && (
<div className="mt-1 text-xs text-[var(--muted-foreground)] italic">
<Emoji emoji="💡" /> {action.reason}
</div>
)}
{action.changes && action.changes.length > 0 && (
<div className="mt-1 space-y-0.5">
<div className="text-xs font-medium text-[var(--muted-foreground)]">
Champs préservés:
</div>
{action.changes.map((change, changeIndex) => (
<div
key={changeIndex}
className="text-xs font-mono text-[var(--foreground)] pl-2 border-l-2 border-orange-400/30"
>
{change}
</div>
))}
</div>
)}
</div>
))}
</div>
</div>
)}
{/* Tâches sans changement */}
{withoutChanges.length > 0 && (
<div className="space-y-3">
{withPreservedFields.length > 0 && (
<div className="text-xs font-semibold text-[var(--muted-foreground)] uppercase tracking-wide flex items-center gap-2">
<div className="h-px flex-1 bg-[var(--border)]"></div>
<span className="text-[var(--muted-foreground)]">
Aucun changement ({withoutChanges.length})
</span>
<div className="h-px flex-1 bg-[var(--border)]"></div>
</div>
)}
<div className="space-y-2">
{withoutChanges.map((action, index) => (
<div
key={index}
className="p-2 bg-[var(--muted)]/5 rounded border border-[var(--border)] opacity-75"
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-baseline gap-2">
<span className="font-mono text-sm font-bold text-[var(--muted-foreground)] shrink-0">
{action.taskKey}
</span>
<span className="text-sm text-[var(--muted-foreground)] truncate">
{action.taskTitle}
</span>
</div>
</div>
<Badge
variant="outline"
size="sm"
className="shrink-0 opacity-75"
>
{getActionLabel(action.type)}
</Badge>
</div>
{action.reason && (
<div className="mt-1 text-xs text-[var(--muted-foreground)] italic">
<Emoji emoji="💡" /> {action.reason}
</div>
)}
</div>
))}
</div>
</div>
)}
</div>
);
}
// Pour les autres types, affichage normal
return (
<div key={type} className="space-y-3"> <div key={type} className="space-y-3">
<h4 <h4
className={`font-bold text-sm flex items-center gap-2 ${getActionColor(type as JiraSyncAction['type'])}`} className={`font-bold text-sm flex items-center gap-2 ${getActionColor(type as JiraSyncAction['type'])}`}
@@ -454,7 +602,8 @@ function SyncActionsList({ actions }: { actions: JiraSyncAction[] }) {
))} ))}
</div> </div>
</div> </div>
))} );
})}
</div> </div>
); );
} }

View File

@@ -383,9 +383,157 @@ function SyncActionsList({ actions }: { actions: TfsSyncAction[] }) {
{} as Record<string, TfsSyncAction[]> {} as Record<string, TfsSyncAction[]>
); );
// Ordre d'affichage : créées, mises à jour, supprimées, ignorées (toujours en dernier)
const displayOrder: TfsSyncAction['type'][] = [
'created',
'updated',
'deleted',
'skipped',
];
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{Object.entries(groupedActions).map(([type, typeActions]) => ( {displayOrder
.filter((type) => groupedActions[type]?.length > 0)
.map((type) => {
const typeActions = groupedActions[type] as TfsSyncAction[];
// Pour les actions skipped, séparer celles avec champs préservés de celles sans changement
if (type === 'skipped') {
const withPreservedFields = typeActions.filter(
(action) => action.changes && action.changes.length > 0
);
const withoutChanges = typeActions.filter(
(action) => !action.changes || action.changes.length === 0
);
return (
<div key={type} className="space-y-4">
<h4
className={`font-bold text-sm flex items-center gap-2 ${getActionColor(type as TfsSyncAction['type'])}`}
>
{getActionIcon(type as TfsSyncAction['type'])}
{getActionLabel(type as TfsSyncAction['type'])} (
{typeActions.length})
</h4>
{/* PRs avec champs préservés */}
{withPreservedFields.length > 0 && (
<div className="space-y-3">
<div className="text-xs font-semibold text-[var(--muted-foreground)] uppercase tracking-wide flex items-center gap-2">
<div className="h-px flex-1 bg-[var(--border)]"></div>
<span className="text-orange-400">
Avec champs préservés ({withPreservedFields.length})
</span>
<div className="h-px flex-1 bg-[var(--border)]"></div>
</div>
<div className="space-y-2">
{withPreservedFields.map((action, index) => (
<div
key={index}
className="p-2 bg-[var(--muted)]/10 rounded border border-orange-400/30"
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-baseline gap-2">
<span className="font-mono text-sm font-bold text-[var(--foreground)] shrink-0">
PR #{action.pullRequestId}
</span>
<span className="text-sm text-[var(--muted-foreground)] truncate">
{action.prTitle}
</span>
</div>
</div>
<Badge
variant="outline"
size="sm"
className="shrink-0"
>
{getActionLabel(action.type)}
</Badge>
</div>
{action.reason && (
<div className="mt-1 text-xs text-[var(--muted-foreground)] italic">
💡 {action.reason}
</div>
)}
{action.changes && action.changes.length > 0 && (
<div className="mt-1 space-y-0.5">
<div className="text-xs font-medium text-[var(--muted-foreground)]">
Champs préservés:
</div>
{action.changes.map((change, changeIndex) => (
<div
key={changeIndex}
className="text-xs font-mono text-[var(--foreground)] pl-2 border-l-2 border-orange-400/30"
>
{change}
</div>
))}
</div>
)}
</div>
))}
</div>
</div>
)}
{/* PRs sans changement */}
{withoutChanges.length > 0 && (
<div className="space-y-3">
{withPreservedFields.length > 0 && (
<div className="text-xs font-semibold text-[var(--muted-foreground)] uppercase tracking-wide flex items-center gap-2">
<div className="h-px flex-1 bg-[var(--border)]"></div>
<span className="text-[var(--muted-foreground)]">
Aucun changement ({withoutChanges.length})
</span>
<div className="h-px flex-1 bg-[var(--border)]"></div>
</div>
)}
<div className="space-y-2">
{withoutChanges.map((action, index) => (
<div
key={index}
className="p-2 bg-[var(--muted)]/5 rounded border border-[var(--border)] opacity-75"
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-baseline gap-2">
<span className="font-mono text-sm font-bold text-[var(--muted-foreground)] shrink-0">
PR #{action.pullRequestId}
</span>
<span className="text-sm text-[var(--muted-foreground)] truncate">
{action.prTitle}
</span>
</div>
</div>
<Badge
variant="outline"
size="sm"
className="shrink-0 opacity-75"
>
{getActionLabel(action.type)}
</Badge>
</div>
{action.reason && (
<div className="mt-1 text-xs text-[var(--muted-foreground)] italic">
💡 {action.reason}
</div>
)}
</div>
))}
</div>
</div>
)}
</div>
);
}
// Pour les autres types, affichage normal
return (
<div key={type} className="space-y-3"> <div key={type} className="space-y-3">
<h4 <h4
className={`font-bold text-sm flex items-center gap-2 ${getActionColor(type as TfsSyncAction['type'])}`} className={`font-bold text-sm flex items-center gap-2 ${getActionColor(type as TfsSyncAction['type'])}`}
@@ -442,7 +590,8 @@ function SyncActionsList({ actions }: { actions: TfsSyncAction[] }) {
))} ))}
</div> </div>
</div> </div>
))} );
})}
</div> </div>
); );
} }

View File

@@ -383,21 +383,168 @@ function SyncActionsList({ actions }: { actions: TfsSyncAction[] }) {
{} as Record<string, TfsSyncAction[]> {} as Record<string, TfsSyncAction[]>
); );
// Ordre d'affichage : créées, mises à jour, supprimées, ignorées (toujours en dernier)
const displayOrder: TfsSyncAction['type'][] = [
'created',
'updated',
'deleted',
'skipped',
];
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{Object.entries(groupedActions).map(([type, typeActions]) => ( {displayOrder
.filter((type) => groupedActions[type]?.length > 0)
.map((type) => {
const typeActions = groupedActions[type] as TfsSyncAction[];
// Pour les actions skipped, séparer celles avec champs préservés de celles sans changement
if (type === 'skipped') {
const withPreservedFields = typeActions.filter(
(action) => action.changes && action.changes.length > 0
);
const withoutChanges = typeActions.filter(
(action) => !action.changes || action.changes.length === 0
);
return (
<div key={type} className="space-y-4">
<h4
className={`font-bold text-sm flex items-center gap-2 ${getActionColor(type as TfsSyncAction['type'])}`}
>
{getActionIcon(type as TfsSyncAction['type'])}
{getActionLabel(type as TfsSyncAction['type'])} (
{typeActions.length})
</h4>
{/* PRs avec champs préservés */}
{withPreservedFields.length > 0 && (
<div className="space-y-3">
<div className="text-xs font-semibold text-[var(--muted-foreground)] uppercase tracking-wide flex items-center gap-2">
<div className="h-px flex-1 bg-[var(--border)]"></div>
<span className="text-orange-400">
Avec champs préservés ({withPreservedFields.length})
</span>
<div className="h-px flex-1 bg-[var(--border)]"></div>
</div>
<div className="space-y-2">
{withPreservedFields.map((action, index) => (
<div
key={index}
className="p-2 bg-[var(--muted)]/10 rounded border border-orange-400/30"
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-baseline gap-2">
<span className="font-mono text-sm font-bold text-[var(--foreground)] shrink-0">
PR #{action.pullRequestId}
</span>
<span className="text-sm text-[var(--muted-foreground)] truncate">
{action.prTitle}
</span>
</div>
</div>
<Badge
variant="outline"
size="sm"
className="shrink-0"
>
{getActionLabel(action.type)}
</Badge>
</div>
{action.reason && (
<div className="mt-1 text-xs text-[var(--muted-foreground)] italic">
💡 {action.reason}
</div>
)}
{action.changes && action.changes.length > 0 && (
<div className="mt-1 space-y-0.5">
<div className="text-xs font-medium text-[var(--muted-foreground)]">
Champs préservés:
</div>
{action.changes.map((change, changeIndex) => (
<div
key={changeIndex}
className="text-xs font-mono text-[var(--foreground)] pl-2 border-l-2 border-orange-400/30"
>
{change}
</div>
))}
</div>
)}
</div>
))}
</div>
</div>
)}
{/* PRs sans changement */}
{withoutChanges.length > 0 && (
<div className="space-y-3">
{withPreservedFields.length > 0 && (
<div className="text-xs font-semibold text-[var(--muted-foreground)] uppercase tracking-wide flex items-center gap-2">
<div className="h-px flex-1 bg-[var(--border)]"></div>
<span className="text-[var(--muted-foreground)]">
Aucun changement ({withoutChanges.length})
</span>
<div className="h-px flex-1 bg-[var(--border)]"></div>
</div>
)}
<div className="space-y-2">
{withoutChanges.map((action, index) => (
<div
key={index}
className="p-2 bg-[var(--muted)]/5 rounded border border-[var(--border)] opacity-75"
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-baseline gap-2">
<span className="font-mono text-sm font-bold text-[var(--muted-foreground)] shrink-0">
PR #{action.pullRequestId}
</span>
<span className="text-sm text-[var(--muted-foreground)] truncate">
{action.prTitle}
</span>
</div>
</div>
<Badge
variant="outline"
size="sm"
className="shrink-0 opacity-75"
>
{getActionLabel(action.type)}
</Badge>
</div>
{action.reason && (
<div className="mt-1 text-xs text-[var(--muted-foreground)] italic">
💡 {action.reason}
</div>
)}
</div>
))}
</div>
</div>
)}
</div>
);
}
// Pour les autres types, affichage normal
return (
<div key={type} className="space-y-3"> <div key={type} className="space-y-3">
<h4 <h4
className={`font-bold text-sm flex items-center gap-2 ${getActionColor(type as TfsSyncAction['type'])}`} className={`font-bold text-sm flex items-center gap-2 ${getActionColor(type as TfsSyncAction['type'])}`}
> >
{getActionIcon(type as TfsSyncAction['type'])} {getActionIcon(type as TfsSyncAction['type'])}
{getActionLabel(type as TfsSyncAction['type'])} ( {getActionLabel(type as TfsSyncAction['type'])} (
{(typeActions as TfsSyncAction[]).length}) {typeActions.length})
</h4> </h4>
<div className="space-y-2"> <div className="space-y-2">
{(typeActions as TfsSyncAction[]).map( {typeActions.map((action, index) => (
(action: TfsSyncAction, index: number) => (
<div <div
key={index} key={index}
className="p-2 bg-[var(--muted)]/10 rounded border border-[var(--border)]" className="p-2 bg-[var(--muted)]/10 rounded border border-[var(--border)]"
@@ -429,24 +576,22 @@ function SyncActionsList({ actions }: { actions: TfsSyncAction[] }) {
<div className="text-xs font-medium text-[var(--muted-foreground)]"> <div className="text-xs font-medium text-[var(--muted-foreground)]">
Modifications: Modifications:
</div> </div>
{action.changes.map( {action.changes.map((change, changeIndex) => (
(change: string, changeIndex: number) => (
<div <div
key={changeIndex} key={changeIndex}
className="text-xs font-mono text-[var(--foreground)] pl-2 border-l-2 border-purple-400/30" className="text-xs font-mono text-[var(--foreground)] pl-2 border-l-2 border-purple-400/30"
> >
{change} {change}
</div> </div>
) ))}
)}
</div> </div>
)} )}
</div> </div>
)
)}
</div>
</div>
))} ))}
</div> </div>
</div>
);
})}
</div>
); );
} }

View File

@@ -4,6 +4,7 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { buildSyncUpdateData } from '@/services/task-management/readonly-fields'; import { buildSyncUpdateData } from '@/services/task-management/readonly-fields';
import { detectSyncChanges } from '@/services/integrations/jira/jira';
describe('Synchronisation Jira - Logique de préservation des champs', () => { describe('Synchronisation Jira - Logique de préservation des champs', () => {
describe('buildSyncUpdateData pour Jira', () => { describe('buildSyncUpdateData pour Jira', () => {
@@ -168,4 +169,263 @@ describe('Synchronisation Jira - Logique de préservation des champs', () => {
expect(result.priority).toBe('medium'); // Préservé car modifié localement expect(result.priority).toBe('medium'); // Préservé car modifié localement
}); });
}); });
describe('detectSyncChanges - Détection des changements et préservations', () => {
it('devrait détecter une priorité préservée localement', () => {
const existingTask = {
title: 'Tâche test',
description: 'Description locale',
status: 'todo',
priority: 'high', // Modifié localement
dueDate: null,
jiraProject: 'TEST',
jiraType: 'Task',
assignee: 'User',
};
const taskData = {
title: 'Tâche test',
description: 'Description locale',
status: 'todo',
priority: 'medium', // Valeur Jira différente
dueDate: null,
jiraProject: 'TEST',
jiraType: 'Task',
assignee: 'User',
};
const finalData = buildSyncUpdateData('jira', existingTask, taskData);
const { realChanges, preservedFields } = detectSyncChanges(
existingTask,
taskData,
finalData
);
expect(realChanges).toHaveLength(0);
expect(preservedFields).toHaveLength(1);
expect(preservedFields[0]).toBe(
'Priorité: préservée localement (high) vs Jira (medium)'
);
});
it('devrait détecter un titre préservé localement', () => {
const existingTask = {
title: 'Titre modifié localement',
description: 'Description',
status: 'todo',
priority: 'medium',
dueDate: null,
jiraProject: 'TEST',
jiraType: 'Task',
assignee: 'User',
};
const taskData = {
title: 'Titre original Jira',
description: 'Description',
status: 'todo',
priority: 'medium',
dueDate: null,
jiraProject: 'TEST',
jiraType: 'Task',
assignee: 'User',
};
const finalData = buildSyncUpdateData('jira', existingTask, taskData);
const { realChanges, preservedFields } = detectSyncChanges(
existingTask,
taskData,
finalData
);
expect(realChanges).toHaveLength(0);
expect(preservedFields).toHaveLength(1);
expect(preservedFields[0]).toContain('Titre: préservé localement');
expect(preservedFields[0]).toContain('vs Jira');
});
it('devrait détecter plusieurs champs préservés', () => {
const existingTask = {
title: 'Titre modifié',
description: 'Description',
status: 'todo',
priority: 'high', // Modifié localement
dueDate: null,
jiraProject: 'TEST',
jiraType: 'Task',
assignee: 'User',
};
const taskData = {
title: 'Titre original', // Différent
description: 'Description',
status: 'todo',
priority: 'medium', // Différent
dueDate: null,
jiraProject: 'TEST',
jiraType: 'Task',
assignee: 'User',
};
const finalData = buildSyncUpdateData('jira', existingTask, taskData);
const { realChanges, preservedFields } = detectSyncChanges(
existingTask,
taskData,
finalData
);
expect(realChanges).toHaveLength(0);
expect(preservedFields.length).toBeGreaterThanOrEqual(2);
expect(preservedFields.some((f) => f.includes('Titre'))).toBe(true);
expect(preservedFields.some((f) => f.includes('Priorité'))).toBe(true);
});
it('devrait détecter un changement réel de statut', () => {
const existingTask = {
title: 'Tâche',
description: 'Description',
status: 'todo',
priority: 'medium',
dueDate: null,
jiraProject: 'TEST',
jiraType: 'Task',
assignee: 'User',
};
const taskData = {
title: 'Tâche',
description: 'Description',
status: 'in_progress', // Changé dans Jira
priority: 'medium',
dueDate: null,
jiraProject: 'TEST',
jiraType: 'Task',
assignee: 'User',
};
const finalData = buildSyncUpdateData('jira', existingTask, taskData);
const { realChanges, preservedFields } = detectSyncChanges(
existingTask,
taskData,
finalData
);
expect(realChanges.length).toBeGreaterThan(0);
expect(realChanges.some((c) => c.includes('Statut'))).toBe(true);
expect(preservedFields).toHaveLength(0);
});
it('devrait détecter changement réel + préservation simultanés', () => {
const existingTask = {
title: 'Titre modifié localement',
description: 'Description locale',
status: 'todo',
priority: 'high', // Modifié localement
dueDate: null,
jiraProject: 'TEST',
jiraType: 'Task',
assignee: 'User',
};
const taskData = {
title: 'Titre original', // Différent
description: 'Description Jira', // Différent
status: 'in_progress', // Changé dans Jira
priority: 'medium', // Différent
dueDate: null,
jiraProject: 'TEST',
jiraType: 'Task',
assignee: 'User',
};
const finalData = buildSyncUpdateData('jira', existingTask, taskData);
const { realChanges, preservedFields } = detectSyncChanges(
existingTask,
taskData,
finalData
);
// Description et statut devraient être dans realChanges (écrasés)
expect(realChanges.length).toBeGreaterThan(0);
// Titre et priorité devraient être préservés
expect(preservedFields.length).toBeGreaterThan(0);
expect(preservedFields.some((f) => f.includes('Titre'))).toBe(true);
expect(preservedFields.some((f) => f.includes('Priorité'))).toBe(true);
});
it('devrait détecter aucun changement quand tout est identique', () => {
const existingTask = {
title: 'Tâche',
description: 'Description',
status: 'todo',
priority: 'medium',
dueDate: null,
jiraProject: 'TEST',
jiraType: 'Task',
assignee: 'User',
};
const taskData = {
title: 'Tâche',
description: 'Description',
status: 'todo',
priority: 'medium',
dueDate: null,
jiraProject: 'TEST',
jiraType: 'Task',
assignee: 'User',
};
const finalData = buildSyncUpdateData('jira', existingTask, taskData);
const { realChanges, preservedFields } = detectSyncChanges(
existingTask,
taskData,
finalData
);
expect(realChanges).toHaveLength(0);
expect(preservedFields).toHaveLength(0);
});
it('devrait détecter un changement de projet Jira', () => {
const existingTask = {
title: 'Tâche',
description: 'Description',
status: 'todo',
priority: 'medium',
dueDate: null,
jiraProject: 'OLD',
jiraType: 'Task',
assignee: 'User',
};
const taskData = {
title: 'Tâche',
description: 'Description',
status: 'todo',
priority: 'medium',
dueDate: null,
jiraProject: 'NEW', // Changé
jiraType: 'Task',
assignee: 'User',
};
const finalData = buildSyncUpdateData('jira', existingTask, taskData);
const { realChanges, preservedFields } = detectSyncChanges(
existingTask,
taskData,
finalData
);
expect(realChanges.length).toBeGreaterThan(0);
expect(realChanges.some((c) => c.includes('Projet'))).toBe(true);
});
});
}); });

View File

@@ -46,6 +46,8 @@ export interface SyncResult {
skipped: number; skipped: number;
deleted: number; deleted: number;
}; };
// Actions originales Jira avec tous les détails (changes, etc.)
jiraActions?: JiraSyncAction[];
} }
export interface JiraSyncResult { export interface JiraSyncResult {
@@ -60,6 +62,126 @@ export interface JiraSyncResult {
actions: JiraSyncAction[]; // Détail des actions effectuées actions: JiraSyncAction[]; // Détail des actions effectuées
} }
/**
* Interface pour les données de tâche existante
*/
interface ExistingTaskData {
title: string;
description: string | null;
status: string;
priority: string;
dueDate: Date | null;
jiraProject: string | null;
jiraType: string | null;
assignee: string | null;
}
/**
* Détecte les changements réels et les préservations lors de la synchronisation
* @param existingTask - Données existantes de la tâche
* @param taskData - Données venant de Jira (avec champs Jira spécifiques)
* @param finalData - Données finales après application de la logique de préservation (sans champs Jira)
* @returns Objet contenant les changements réels et les champs préservés
*/
export function detectSyncChanges(
existingTask: ExistingTaskData,
taskData: {
title: string;
description: string | null;
status: string;
priority: string;
dueDate: Date | null;
jiraProject: string;
jiraType: string;
assignee: string | null;
},
finalData: {
title: string;
description: string | null;
status: string;
priority: string;
dueDate: Date | null;
}
): { realChanges: string[]; preservedFields: string[] } {
const realChanges: string[] = [];
const preservedFields: string[] = [];
// Détecter les changements réels et les préservations
// Pour chaque champ, comparer existingTask avec taskData pour voir les différences
if (existingTask.title !== taskData.title) {
if (existingTask.title !== finalData.title) {
// Changement réel appliqué
realChanges.push(`Titre: ${existingTask.title}${finalData.title}`);
} else {
// Valeur préservée (finalData === existingTask mais différent de taskData)
preservedFields.push(
`Titre: préservé localement ("${existingTask.title}") vs Jira ("${taskData.title}")`
);
}
}
if (existingTask.description !== taskData.description) {
if (existingTask.description !== finalData.description) {
realChanges.push(`Description modifiée`);
}
}
if (existingTask.status !== taskData.status) {
if (existingTask.status !== finalData.status) {
realChanges.push(`Statut: ${existingTask.status}${finalData.status}`);
} else {
// Valeur préservée (finalData === existingTask mais différent de taskData)
preservedFields.push(
`Statut: préservé localement (${existingTask.status}) vs Jira (${taskData.status})`
);
}
}
if (existingTask.priority !== taskData.priority) {
if (existingTask.priority !== finalData.priority) {
realChanges.push(
`Priorité: ${existingTask.priority}${finalData.priority}`
);
} else {
// Valeur préservée (finalData === existingTask mais différent de taskData)
preservedFields.push(
`Priorité: préservée localement (${existingTask.priority}) vs Jira (${taskData.priority})`
);
}
}
if (
(existingTask.dueDate?.getTime() || null) !==
(finalData.dueDate?.getTime() || null)
) {
const oldDate = existingTask.dueDate
? formatDateForDisplay(existingTask.dueDate)
: 'Aucune';
const newDate = finalData.dueDate
? formatDateForDisplay(finalData.dueDate)
: 'Aucune';
realChanges.push(`Échéance: ${oldDate}${newDate}`);
}
if (existingTask.jiraProject !== taskData.jiraProject) {
realChanges.push(
`Projet: ${existingTask.jiraProject}${taskData.jiraProject}`
);
}
if (existingTask.jiraType !== taskData.jiraType) {
realChanges.push(`Type: ${existingTask.jiraType}${taskData.jiraType}`);
}
if (existingTask.assignee !== taskData.assignee) {
realChanges.push(
`Assigné: ${existingTask.assignee}${taskData.assignee}`
);
}
return { realChanges, preservedFields };
}
export class JiraService { export class JiraService {
readonly config: JiraConfig; readonly config: JiraConfig;
@@ -437,6 +559,8 @@ export class JiraService {
// Déterminer le succès et enregistrer le log // Déterminer le succès et enregistrer le log
result.success = result.errors.length === 0; result.success = result.errors.length === 0;
// Ajouter les actions Jira originales pour préserver les détails (changes, etc.)
result.jiraActions = jiraActions;
await this.logSync(result); await this.logSync(result);
console.log('✅ Synchronisation Jira terminée:', result); console.log('✅ Synchronisation Jira terminée:', result);
@@ -531,65 +655,15 @@ export class JiraService {
// Utiliser la logique centralisée pour déterminer quels champs préserver // Utiliser la logique centralisée pour déterminer quels champs préserver
const finalData = buildSyncUpdateData('jira', existingTask, taskData); const finalData = buildSyncUpdateData('jira', existingTask, taskData);
// Détecter les changements et créer la liste des modifications // Détecter les changements réels et préservations
const changes: string[] = []; const { realChanges, preservedFields } = detectSyncChanges(
existingTask,
// Détecter les changements en comparant les valeurs finales avec les existantes taskData,
if (existingTask.title !== finalData.title) { finalData
changes.push(`Titre: ${existingTask.title}${finalData.title}`);
} else if (existingTask.title !== taskData.title) {
// Valeur préservée (finalData === existingTask mais différent de taskData)
changes.push(`Titre: préservé localement ("${existingTask.title}")`);
}
if (existingTask.description !== finalData.description) {
changes.push(`Description modifiée`);
}
if (existingTask.status !== finalData.status) {
changes.push(`Statut: ${existingTask.status}${finalData.status}`);
} else if (existingTask.status !== taskData.status) {
// Valeur préservée (finalData === existingTask mais différent de taskData)
changes.push(`Statut: préservé localement (${existingTask.status})`);
}
if (existingTask.priority !== finalData.priority) {
changes.push(
`Priorité: ${existingTask.priority}${finalData.priority}`
); );
} else if (existingTask.priority !== taskData.priority) {
// Valeur préservée (finalData === existingTask mais différent de taskData)
changes.push(
`Priorité: préservée localement (${existingTask.priority})`
);
}
if (
(existingTask.dueDate?.getTime() || null) !==
(finalData.dueDate?.getTime() || null)
) {
const oldDate = existingTask.dueDate
? formatDateForDisplay(existingTask.dueDate)
: 'Aucune';
const newDate = finalData.dueDate
? formatDateForDisplay(finalData.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) { // Si tous les champs sont préservés et aucun changement réel, skip
if (realChanges.length === 0) {
console.log( console.log(
`⏭️ Aucun changement pour ${jiraTask.key}, skip mise à jour` `⏭️ Aucun changement pour ${jiraTask.key}, skip mise à jour`
); );
@@ -601,7 +675,8 @@ export class JiraService {
type: 'skipped', type: 'skipped',
taskKey: jiraTask.key, taskKey: jiraTask.key,
taskTitle: jiraTask.summary, taskTitle: jiraTask.summary,
reason: 'Aucun changement détecté', reason: 'Tous les champs préservés localement',
changes: preservedFields.length > 0 ? preservedFields : undefined,
}; };
} }
@@ -642,14 +717,17 @@ export class JiraService {
// S'assurer que le tag Jira est assigné (pour les anciennes tâches) // S'assurer que le tag Jira est assigné (pour les anciennes tâches)
await this.assignJiraTag(existingTask.id, userId); await this.assignJiraTag(existingTask.id, userId);
// Construire la liste complète des changements (réels + préservations pour info)
const allChanges = [...realChanges, ...preservedFields];
console.log( console.log(
`🔄 Tâche mise à jour (titre/priorité préservés): ${jiraTask.key} (${changes.length} changement${changes.length > 1 ? 's' : ''})` `🔄 Tâche mise à jour: ${jiraTask.key} (${realChanges.length} changement${realChanges.length > 1 ? 's' : ''} réel${realChanges.length > 1 ? 's' : ''}${preservedFields.length > 0 ? `, ${preservedFields.length} préservé${preservedFields.length > 1 ? 's' : ''}` : ''})`
); );
return { return {
type: 'updated', type: 'updated',
taskKey: jiraTask.key, taskKey: jiraTask.key,
taskTitle: jiraTask.summary, taskTitle: jiraTask.summary,
changes, changes: allChanges,
}; };
} }
} }