feat: jira and synchro

This commit is contained in:
Julien Froidefond
2025-09-17 13:56:42 +02:00
parent 2f104109db
commit 625e8dba4b
24 changed files with 1821 additions and 140 deletions

View File

@@ -7,7 +7,7 @@ declare global {
// Créer une instance unique de Prisma Client
export const prisma = globalThis.__prisma || new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
log: ['error'], // Désactiver les logs query/warn pour éviter le bruit
});
// En développement, stocker l'instance globalement pour éviter les reconnexions

618
services/jira.ts Normal file
View File

@@ -0,0 +1,618 @@
/**
* 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;
}
export interface JiraSyncResult {
success: boolean;
tasksFound: number;
tasksCreated: number;
tasksUpdated: number;
tasksSkipped: number;
errors: string[];
}
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;
}
}
/**
* 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 startAt = 0;
const maxResults = 100; // Taille des pages
let hasMorePages = true;
console.log('🔄 Récupération paginée des tickets Jira...');
while (hasMorePages) {
const requestBody = {
jql,
fields
};
console.log(`📄 Page ${Math.floor(startAt / maxResults) + 1} (tickets ${startAt + 1}-${startAt + maxResults})`);
const response = await this.makeJiraRequest(
`/rest/api/3/search/jql?startAt=${startAt}&maxResults=${maxResults}`,
'POST',
requestBody
);
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[], total: number, maxResults: number, startAt: number };
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: ${allIssues.length})`);
// Vérifier s'il y a plus de pages
hasMorePages = data.issues.length === maxResults && allIssues.length < (data.total || Number.MAX_SAFE_INTEGER);
startAt += maxResults;
// Sécurité: éviter les boucles infinies
if (allIssues.length > 5000) {
console.warn('⚠️ Limite de sécurité atteinte (5000 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
}
}
/**
* Nettoie les epics Jira de la base (ne doivent plus être synchronisés)
*/
async cleanupEpics(): Promise<number> {
try {
console.log('🧹 Nettoyage des epics Jira...');
// D'abord, listons toutes les tâches Jira pour voir lesquelles sont des epics
const allJiraTasks = await prisma.task.findMany({
where: { source: 'jira' }
});
console.log(`🔍 ${allJiraTasks.length} tâches Jira trouvées:`);
allJiraTasks.forEach(task => {
// @ts-expect-error - jiraType existe mais n'est pas encore dans les types générés
console.log(` - ${task.jiraKey}: "${task.title}" [${task.jiraType || 'N/A'}] (ID: ${task.id})`);
});
// Trouver les tâches Jira qui sont des epics
// Maintenant on peut utiliser le type Jira mappé directement !
const epicsToDelete = await prisma.task.findMany({
where: {
source: 'jira',
// @ts-expect-error - jiraType existe mais n'est pas encore dans les types générés
jiraType: 'Epic' // Maintenant standardisé grâce au mapping
}
});
if (epicsToDelete.length > 0) {
// Supprimer les relations de tags d'abord
await prisma.taskTag.deleteMany({
where: {
taskId: { in: epicsToDelete.map(task => task.id) }
}
});
// Supprimer les tâches epics
const result = await prisma.task.deleteMany({
where: {
id: { in: epicsToDelete.map(task => task.id) }
}
});
console.log(`${result.count} epics supprimés de la base`);
return result.count;
} else {
console.log('✅ Aucun epic trouvé à nettoyer');
return 0;
}
} catch (error) {
console.error('Erreur lors du nettoyage des epics:', error);
throw error;
}
}
/**
* 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,
errors: []
};
try {
console.log('🔄 Début de la synchronisation Jira...');
// Nettoyer les epics existants (une seule fois)
await this.cleanupEpics();
// S'assurer que le tag "From Jira" existe
await this.ensureJiraTagExists();
// Récupérer les tickets Jira
const jiraTasks = await this.getAssignedIssues();
result.tasksFound = jiraTasks.length;
console.log(`📋 ${jiraTasks.length} tickets trouvés dans Jira`);
// Synchroniser chaque ticket
for (const jiraTask of jiraTasks) {
try {
const syncResult = await this.syncSingleTask(jiraTask);
if (syncResult === 'created') {
result.tasksCreated++;
} else if (syncResult === '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'}`);
}
}
// 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<'created' | 'updated' | 'skipped'> {
// 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 les tags Jira
await this.assignJiraTag(newTask.id);
await this.assignProjectTag(newTask.id, jiraTask.project.key);
console.log(` Nouvelle tâche créée: ${jiraTask.key}`);
return 'created';
} else {
// Vérifier si mise à jour nécessaire (seulement si pas de modifs locales récentes)
const jiraUpdated = new Date(jiraTask.updated);
const localUpdated = existingTask.updatedAt;
// Si la tâche locale a été modifiée après la dernière update Jira, on skip
if (localUpdated > jiraUpdated) {
console.log(`⏭️ Tâche ${jiraTask.key} modifiée localement, skip mise à jour`);
return 'skipped';
}
// Mettre à jour seulement les champs Jira (pas les modifs locales)
await prisma.task.update({
where: { id: existingTask.id },
data: {
title: taskData.title,
description: taskData.description,
status: taskData.status,
priority: taskData.priority,
dueDate: taskData.dueDate,
jiraProject: taskData.jiraProject,
jiraKey: taskData.jiraKey,
// @ts-expect-error - jiraType existe mais n'est pas encore dans les types générés
jiraType: taskData.jiraType,
assignee: taskData.assignee,
updatedAt: taskData.updatedAt
}
});
// S'assurer que les tags Jira sont assignés (pour les anciennes tâches)
await this.assignJiraTag(existingTask.id);
await this.assignProjectTag(existingTask.id, jiraTask.project.key);
console.log(`🔄 Tâche mise à jour: ${jiraTask.key}`);
return 'updated';
}
}
/**
* 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
}
}
/**
* Assigne un tag de projet Jira à une tâche (crée le tag si nécessaire)
*/
private async assignProjectTag(taskId: string, projectKey: string): Promise<void> {
try {
const tagName = `📋 ${projectKey}`;
// Vérifier si le tag projet existe déjà
let projectTag = await prisma.tag.findUnique({
where: { name: tagName }
});
if (!projectTag) {
// Créer le tag projet s'il n'existe pas
projectTag = await prisma.tag.create({
data: {
name: tagName,
color: '#4F46E5', // Violet pour les projets
isPinned: false
}
});
console.log(`✅ Tag projet "${tagName}" créé automatiquement`);
}
// Vérifier si le tag est déjà assigné
const existingAssignment = await prisma.taskTag.findUnique({
where: {
taskId_tagId: {
taskId: taskId,
tagId: projectTag.id
}
}
});
if (!existingAssignment) {
// Créer l'assignation du tag
await prisma.taskTag.create({
data: {
taskId: taskId,
tagId: projectTag.id
}
});
console.log(`🏷️ Tag projet "${tagName}" assigné à la tâche`);
}
} catch (error) {
console.error('Erreur lors de l\'assignation du tag projet:', 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 "To Do"
'To Do': 'todo',
'Open': 'todo',
'Backlog': 'todo',
'Selected for Development': 'todo',
// Statuts "In Progress"
'In Progress': 'in_progress',
'In Review': 'in_progress',
'Code Review': 'in_progress',
'Testing': 'in_progress',
// Statuts "Done"
'Done': 'done',
'Closed': 'done',
'Resolved': 'done',
'Complete': 'done',
// Statuts bloqués
'Blocked': 'blocked',
'On Hold': 'blocked'
};
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': 'critical',
'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 });
}

View File

@@ -319,6 +319,8 @@ export class TasksService {
updatedAt: prismaTask.updatedAt,
jiraProject: prismaTask.jiraProject ?? undefined,
jiraKey: prismaTask.jiraKey ?? undefined,
// @ts-expect-error - jiraType existe mais n'est pas encore dans les types générés
jiraType: prismaTask.jiraType ?? undefined,
assignee: prismaTask.assignee ?? undefined
};
}