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:
@@ -118,6 +118,17 @@ export async function POST(request: Request) {
|
||||
const syncResult = await jiraService.syncTasks(session.user.id);
|
||||
|
||||
// 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 = {
|
||||
success: syncResult.success,
|
||||
tasksFound: syncResult.totalItems,
|
||||
@@ -127,13 +138,7 @@ export async function POST(request: Request) {
|
||||
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(),
|
||||
taskTitle: action.title,
|
||||
reason: action.message,
|
||||
changes: action.message ? [action.message] : undefined,
|
||||
})),
|
||||
actions,
|
||||
};
|
||||
|
||||
if (syncResult.success) {
|
||||
|
||||
@@ -395,9 +395,157 @@ function SyncActionsList({ actions }: { actions: 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 (
|
||||
<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">
|
||||
<h4
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -383,9 +383,157 @@ function SyncActionsList({ actions }: { actions: 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 (
|
||||
<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">
|
||||
<h4
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -383,21 +383,168 @@ function SyncActionsList({ actions }: { actions: 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 (
|
||||
<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">
|
||||
<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 as TfsSyncAction[]).length})
|
||||
{typeActions.length})
|
||||
</h4>
|
||||
|
||||
<div className="space-y-2">
|
||||
{(typeActions as TfsSyncAction[]).map(
|
||||
(action: TfsSyncAction, index: number) => (
|
||||
{typeActions.map((action, index) => (
|
||||
<div
|
||||
key={index}
|
||||
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)]">
|
||||
Modifications:
|
||||
</div>
|
||||
{action.changes.map(
|
||||
(change: string, changeIndex: number) => (
|
||||
{action.changes.map((change, changeIndex) => (
|
||||
<div
|
||||
key={changeIndex}
|
||||
className="text-xs font-mono text-[var(--foreground)] pl-2 border-l-2 border-purple-400/30"
|
||||
>
|
||||
{change}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
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('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
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,6 +46,8 @@ export interface SyncResult {
|
||||
skipped: number;
|
||||
deleted: number;
|
||||
};
|
||||
// Actions originales Jira avec tous les détails (changes, etc.)
|
||||
jiraActions?: JiraSyncAction[];
|
||||
}
|
||||
|
||||
export interface JiraSyncResult {
|
||||
@@ -60,6 +62,126 @@ export interface JiraSyncResult {
|
||||
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 {
|
||||
readonly config: JiraConfig;
|
||||
|
||||
@@ -437,6 +559,8 @@ export class JiraService {
|
||||
|
||||
// Déterminer le succès et enregistrer le log
|
||||
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);
|
||||
|
||||
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
|
||||
const finalData = buildSyncUpdateData('jira', existingTask, taskData);
|
||||
|
||||
// Détecter les changements et créer la liste des modifications
|
||||
const changes: string[] = [];
|
||||
|
||||
// Détecter les changements en comparant les valeurs finales avec les existantes
|
||||
if (existingTask.title !== finalData.title) {
|
||||
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}`
|
||||
// Détecter les changements réels et préservations
|
||||
const { realChanges, preservedFields } = detectSyncChanges(
|
||||
existingTask,
|
||||
taskData,
|
||||
finalData
|
||||
);
|
||||
} 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(
|
||||
`⏭️ Aucun changement pour ${jiraTask.key}, skip mise à jour`
|
||||
);
|
||||
@@ -601,7 +675,8 @@ export class JiraService {
|
||||
type: 'skipped',
|
||||
taskKey: jiraTask.key,
|
||||
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)
|
||||
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(
|
||||
`🔄 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 {
|
||||
type: 'updated',
|
||||
taskKey: jiraTask.key,
|
||||
taskTitle: jiraTask.summary,
|
||||
changes,
|
||||
changes: allChanges,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user