- Updated `JiraConfigForm` to include an input for ignored projects, allowing users to specify projects to exclude from synchronization. - Enhanced `JiraService` with a method to filter out tasks from ignored projects, improving task management. - Modified user preferences to store ignored projects, ensuring persistence across sessions. - Updated API routes to handle ignored projects in configuration, enhancing overall functionality. - Marked the corresponding task as complete in TODO.md.
709 lines
23 KiB
TypeScript
709 lines
23 KiB
TypeScript
/**
|
||
* Service de gestion Jira Cloud
|
||
* Intégration unidirectionnelle Jira → TowerControl
|
||
*/
|
||
|
||
import { JiraTask } from '@/lib/types';
|
||
import { prisma } from './database';
|
||
|
||
export interface JiraConfig {
|
||
baseUrl: string;
|
||
email: string;
|
||
apiToken: string;
|
||
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
|
||
}
|
||
|
||
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 {
|
||
private config: JiraConfig;
|
||
|
||
constructor(config: JiraConfig) {
|
||
this.config = config;
|
||
}
|
||
|
||
/**
|
||
* Teste la connexion à Jira
|
||
*/
|
||
async testConnection(): Promise<boolean> {
|
||
try {
|
||
const response = await this.makeJiraRequest('/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;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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 assignés à l'utilisateur connecté avec pagination
|
||
*/
|
||
async getAssignedIssues(): Promise<JiraTask[]> {
|
||
try {
|
||
const jql = 'assignee = currentUser() AND resolution = Unresolved AND issuetype != Epic ORDER BY updated DESC';
|
||
const fields = ['id', 'key', 'summary', 'description', 'status', 'priority', 'assignee', 'project', 'issuetype', '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.makeJiraRequest('/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;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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<JiraSyncResult> {
|
||
const result: JiraSyncResult = {
|
||
success: false,
|
||
tasksFound: 0,
|
||
tasksCreated: 0,
|
||
tasksUpdated: 0,
|
||
tasksSkipped: 0,
|
||
tasksDeleted: 0,
|
||
errors: [],
|
||
actions: []
|
||
};
|
||
|
||
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.tasksFound = 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);
|
||
|
||
// Ajouter l'action au résultat
|
||
result.actions.push(syncAction);
|
||
|
||
// Compter les actions
|
||
if (syncAction.type === 'created') {
|
||
result.tasksCreated++;
|
||
} else if (syncAction.type === 'updated') {
|
||
result.tasksUpdated++;
|
||
} else {
|
||
result.tasksSkipped++;
|
||
}
|
||
} 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);
|
||
result.tasksDeleted = deletedActions.length;
|
||
result.actions.push(...deletedActions);
|
||
|
||
// 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 ? new Date(jiraTask.duedate) : null,
|
||
jiraProject: jiraTask.project.key,
|
||
jiraKey: jiraTask.key,
|
||
jiraType: this.mapJiraTypeToDisplay(jiraTask.issuetype.name),
|
||
assignee: jiraTask.assignee?.displayName || null,
|
||
updatedAt: new Date(jiraTask.updated)
|
||
};
|
||
|
||
if (!existingTask) {
|
||
// Créer nouvelle tâche avec le tag Jira
|
||
const newTask = await prisma.task.create({
|
||
data: {
|
||
...taskData,
|
||
createdAt: new Date(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 ? existingTask.dueDate.toLocaleDateString() : 'Aucune';
|
||
const newDate = taskData.dueDate ? taskData.dueDate.toLocaleDateString() : '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
|
||
*/
|
||
private async makeJiraRequest(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: JiraSyncResult): 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.tasksCreated + result.tasksUpdated
|
||
}
|
||
});
|
||
} 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({ baseUrl, email, apiToken });
|
||
}
|