Files
towercontrol/services/jira.ts
Julien Froidefond 78a96b9c92 feat: add project key support for Jira analytics
- Introduced `projectKey` and `ignoredProjects` fields in Jira configuration to enhance analytics capabilities.
- Implemented project validation logic in `JiraConfigClient` and integrated it into the `JiraConfigForm` for user input.
- Updated `IntegrationsSettingsPageClient` to display analytics dashboard link based on the configured project key.
- Enhanced API routes to handle project key in Jira sync and user preferences.
- Marked related tasks as complete in `TODO.md`.
2025-09-18 22:08:29 +02:00

754 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;
projectKey?: string; // Clé du projet à surveiller pour les analytics d'équipe (ex: "MYTEAM")
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;
}
/**
* Valide l'existence d'un projet Jira
*/
async validateProject(projectKey: string): Promise<{ exists: boolean; name?: string; error?: string }> {
try {
const response = await this.makeJiraRequestPrivate(`/rest/api/3/project/${projectKey}`);
if (response.status === 404) {
return { exists: false, error: `Projet "${projectKey}" introuvable` };
}
if (!response.ok) {
const errorText = await response.text();
return { exists: false, error: `Erreur API: ${response.status} - ${errorText}` };
}
const project = await response.json();
return {
exists: true,
name: project.name
};
} catch (error) {
console.error('Erreur lors de la validation du projet:', error);
return {
exists: false,
error: error instanceof Error ? error.message : 'Erreur de connexion'
};
}
}
/**
* Teste la connexion à Jira
*/
async testConnection(): Promise<boolean> {
try {
const response = await this.makeJiraRequestPrivate('/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 avec une requête JQL personnalisée avec pagination
*/
async searchIssues(jql: string): Promise<JiraTask[]> {
try {
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.makeJiraRequestPrivate('/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;
}
}
/**
* Récupère les tickets assignés à l'utilisateur connecté
*/
async getAssignedIssues(): Promise<JiraTask[]> {
const jql = 'assignee = currentUser() AND resolution = Unresolved AND issuetype != Epic ORDER BY updated DESC';
return this.searchIssues(jql);
}
/**
* 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 (méthode publique pour analytics)
*/
async makeJiraRequest(endpoint: string, method: string = 'GET', body?: unknown): Promise<Response> {
return this.makeJiraRequestPrivate(endpoint, method, body);
}
/**
* Effectue une requête à l'API Jira avec authentification
*/
private async makeJiraRequestPrivate(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 });
}