Files
towercontrol/services/jira-analytics.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

462 lines
16 KiB
TypeScript

/**
* Service d'analytics Jira pour la surveillance d'équipe
* Calcule des métriques avancées sur un projet spécifique
*/
import { JiraService } from './jira';
import {
JiraAnalytics,
JiraTask,
AssigneeDistribution,
SprintVelocity,
CycleTimeByType,
StatusDistribution,
AssigneeWorkload
} from '@/lib/types';
export interface JiraAnalyticsConfig {
baseUrl: string;
email: string;
apiToken: string;
projectKey: string;
}
export class JiraAnalyticsService {
private jiraService: JiraService;
private projectKey: string;
constructor(config: JiraAnalyticsConfig) {
this.jiraService = new JiraService(config);
this.projectKey = config.projectKey;
}
/**
* Récupère toutes les analytics du projet
*/
async getProjectAnalytics(): Promise<JiraAnalytics> {
try {
console.log(`📊 Début de l'analyse du projet ${this.projectKey}...`);
// Récupérer les informations du projet
const projectInfo = await this.getProjectInfo();
// Récupérer tous les tickets du projet (pas seulement assignés)
const allIssues = await this.getAllProjectIssues();
console.log(`📋 ${allIssues.length} tickets récupérés pour l'analyse`);
// Calculer les différentes métriques
const [
teamMetrics,
velocityMetrics,
cycleTimeMetrics,
workInProgress
] = await Promise.all([
this.calculateTeamMetrics(allIssues),
this.calculateVelocityMetrics(allIssues),
this.calculateCycleTimeMetrics(allIssues),
this.calculateWorkInProgress(allIssues)
]);
return {
project: {
key: this.projectKey,
name: projectInfo.name,
totalIssues: allIssues.length
},
teamMetrics,
velocityMetrics,
cycleTimeMetrics,
workInProgress
};
} catch (error) {
console.error('Erreur lors du calcul des analytics:', error);
throw error;
}
}
/**
* Récupère les informations de base du projet
*/
private async getProjectInfo(): Promise<{ name: string }> {
const validation = await this.jiraService.validateProject(this.projectKey);
if (!validation.exists) {
throw new Error(`Projet ${this.projectKey} introuvable`);
}
return { name: validation.name || this.projectKey };
}
/**
* Récupère TOUS les tickets du projet (pas seulement assignés à l'utilisateur)
*/
private async getAllProjectIssues(): Promise<JiraTask[]> {
try {
const jql = `project = "${this.projectKey}" ORDER BY created DESC`;
// Utiliser la nouvelle méthode searchIssues qui gère la pagination correctement
const jiraTasks = await this.jiraService.searchIssues(jql);
// Retourner les tâches mappées (elles sont déjà converties par searchIssues)
return jiraTasks;
} catch (error) {
console.error('Erreur lors de la récupération des tickets du projet:', error);
throw error;
}
}
/**
* Calcule les métriques d'équipe (répartition par assignee)
*/
private async calculateTeamMetrics(issues: JiraTask[]): Promise<{
totalAssignees: number;
activeAssignees: number;
issuesDistribution: AssigneeDistribution[];
}> {
const assigneeStats = new Map<string, {
displayName: string;
total: number;
completed: number;
inProgress: number;
}>();
// Analyser chaque ticket
issues.forEach(issue => {
const assignee = issue.assignee;
const status = issue.status?.name || 'Unknown';
// Utiliser "Unassigned" si pas d'assignee
const assigneeKey = assignee?.emailAddress || 'unassigned';
const displayName = assignee?.displayName || 'Non assigné';
if (!assigneeStats.has(assigneeKey)) {
assigneeStats.set(assigneeKey, {
displayName,
total: 0,
completed: 0,
inProgress: 0
});
}
const stats = assigneeStats.get(assigneeKey)!;
stats.total++;
// Catégoriser par statut (logique simplifiée)
const statusLower = status.toLowerCase();
if (statusLower.includes('done') || statusLower.includes('closed') || statusLower.includes('resolved')) {
stats.completed++;
} else if (statusLower.includes('progress') || statusLower.includes('review') || statusLower.includes('testing')) {
stats.inProgress++;
}
});
// Convertir en tableau et calculer les pourcentages
const distribution: AssigneeDistribution[] = Array.from(assigneeStats.entries()).map(([assignee, stats]) => ({
assignee,
displayName: stats.displayName,
totalIssues: stats.total,
completedIssues: stats.completed,
inProgressIssues: stats.inProgress,
percentage: Math.round((stats.total / issues.length) * 100)
})).sort((a, b) => b.totalIssues - a.totalIssues);
const activeAssignees = distribution.filter(d => d.inProgressIssues > 0).length;
return {
totalAssignees: assigneeStats.size,
activeAssignees,
issuesDistribution: distribution
};
}
/**
* Calcule les métriques de vélocité (basées sur les story points)
*/
private async calculateVelocityMetrics(issues: JiraTask[]): Promise<{
currentSprintPoints: number;
averageVelocity: number;
sprintHistory: SprintVelocity[];
}> {
// Pour l'instant, implémentation basique
// TODO: Intégrer avec l'API Jira Agile pour les vrais sprints
const completedIssues = issues.filter(issue => {
const statusCategory = issue.status?.category?.toLowerCase();
const statusName = issue.status?.name?.toLowerCase() || '';
// Support Jira français ET anglais
const isCompleted = statusCategory === 'done' ||
statusCategory === 'terminé' ||
statusName.includes('done') ||
statusName.includes('closed') ||
statusName.includes('resolved') ||
statusName.includes('complete') ||
statusName.includes('fait') ||
statusName.includes('clôturé') ||
statusName.includes('cloturé') ||
statusName.includes('en production') ||
statusName.includes('finished') ||
statusName.includes('delivered');
return isCompleted;
});
// Calculer les points (1 point par ticket pour simplifier)
const getStoryPoints = () => {
return 1; // Simplifié pour l'instant, pas de story points dans JiraTask
};
const currentSprintPoints = completedIssues
.reduce((sum) => sum + getStoryPoints(), 0);
// Créer un historique basé sur les données réelles des 4 dernières périodes
const sprintHistory = this.generateSprintHistoryFromIssues(issues, completedIssues);
const averageVelocity = sprintHistory.length > 0
? Math.round(sprintHistory.reduce((sum, sprint) => sum + sprint.completedPoints, 0) / sprintHistory.length)
: 0;
return {
currentSprintPoints,
averageVelocity,
sprintHistory
};
}
/**
* Génère un historique de sprints basé sur les dates de création/résolution des tickets
*/
private generateSprintHistoryFromIssues(allIssues: JiraTask[], completedIssues: JiraTask[]): SprintVelocity[] {
const now = new Date();
const sprintHistory: SprintVelocity[] = [];
// Créer 4 périodes de 2 semaines (8 semaines au total)
for (let i = 3; i >= 0; i--) {
const endDate = new Date(now.getTime() - (i * 14 * 24 * 60 * 60 * 1000));
const startDate = new Date(endDate.getTime() - (14 * 24 * 60 * 60 * 1000));
// Compter les tickets complétés dans cette période
const completedInPeriod = completedIssues.filter(issue => {
const updatedDate = new Date(issue.updated);
return updatedDate >= startDate && updatedDate <= endDate;
});
// Compter les tickets créés dans cette période (approximation du planifié)
const createdInPeriod = allIssues.filter(issue => {
const createdDate = new Date(issue.created);
return createdDate >= startDate && createdDate <= endDate;
});
const completedPoints = completedInPeriod.length;
const plannedPoints = Math.max(completedPoints, createdInPeriod.length);
const completionRate = plannedPoints > 0 ? Math.round((completedPoints / plannedPoints) * 100) : 0;
sprintHistory.push({
sprintName: i === 0 ? 'Sprint actuel' : `Sprint -${i}`,
startDate: startDate.toISOString(),
endDate: endDate.toISOString(),
completedPoints,
plannedPoints,
completionRate
});
}
return sprintHistory;
}
/**
* Calcule les métriques de cycle time
*/
private async calculateCycleTimeMetrics(issues: JiraTask[]): Promise<{
averageCycleTime: number;
cycleTimeByType: CycleTimeByType[];
}> {
const completedIssues = issues.filter(issue => {
const statusCategory = issue.status?.category?.toLowerCase();
const statusName = issue.status?.name?.toLowerCase() || '';
// Support Jira français ET anglais
return statusCategory === 'done' ||
statusCategory === 'terminé' ||
statusName.includes('done') ||
statusName.includes('closed') ||
statusName.includes('resolved') ||
statusName.includes('complete') ||
statusName.includes('fait') ||
statusName.includes('clôturé') ||
statusName.includes('cloturé') ||
statusName.includes('en production') ||
statusName.includes('finished') ||
statusName.includes('delivered');
});
// Calculer le cycle time (de création à résolution)
const cycleTimes = completedIssues
.filter(issue => issue.created && issue.updated) // S'assurer qu'on a les dates
.map(issue => {
const created = new Date(issue.created);
const resolved = new Date(issue.updated);
const days = Math.max(0.1, (resolved.getTime() - created.getTime()) / (1000 * 60 * 60 * 24)); // Minimum 0.1 jour
return Math.round(days * 10) / 10; // Arrondir à 1 décimale
})
.filter(time => time > 0 && time < 365); // Filtrer les valeurs aberrantes (plus d'un an)
const averageCycleTime = cycleTimes.length > 0
? Math.round(cycleTimes.reduce((sum, time) => sum + time, 0) / cycleTimes.length * 10) / 10
: 0;
// Grouper par type d'issue (recalculer avec les données filtrées)
const validCompletedIssues = completedIssues.filter(issue => issue.created && issue.updated);
const typeStats = new Map<string, number[]>();
validCompletedIssues.forEach((issue, index) => {
if (index < cycleTimes.length) { // Sécurité pour éviter l'index out of bounds
const issueType = issue.issuetype?.name || 'Unknown';
if (!typeStats.has(issueType)) {
typeStats.set(issueType, []);
}
const cycleTime = cycleTimes[index];
if (cycleTime > 0 && cycleTime < 365) { // Même filtre que plus haut
typeStats.get(issueType)!.push(cycleTime);
}
}
});
const cycleTimeByType: CycleTimeByType[] = Array.from(typeStats.entries()).map(([type, times]) => {
const average = times.reduce((sum, time) => sum + time, 0) / times.length;
const sorted = [...times].sort((a, b) => a - b);
const median = sorted.length % 2 === 0
? (sorted[sorted.length / 2 - 1] + sorted[sorted.length / 2]) / 2
: sorted[Math.floor(sorted.length / 2)];
return {
issueType: type,
averageDays: Math.round(average * 10) / 10,
medianDays: Math.round(median * 10) / 10,
samples: times.length
};
}).sort((a, b) => b.samples - a.samples);
return {
averageCycleTime,
cycleTimeByType
};
}
/**
* Calcule le work in progress (WIP)
*/
private async calculateWorkInProgress(issues: JiraTask[]): Promise<{
byStatus: StatusDistribution[];
byAssignee: AssigneeWorkload[];
}> {
// Grouper par statut
const statusCounts = new Map<string, number>();
issues.forEach(issue => {
const status = issue.status?.name || 'Unknown';
statusCounts.set(status, (statusCounts.get(status) || 0) + 1);
});
const byStatus: StatusDistribution[] = Array.from(statusCounts.entries()).map(([status, count]) => ({
status,
count,
percentage: Math.round((count / issues.length) * 100)
})).sort((a, b) => b.count - a.count);
// Grouper par assignee (WIP seulement)
const wipIssues = issues.filter(issue => {
const statusCategory = issue.status?.category?.toLowerCase();
const statusName = issue.status?.name?.toLowerCase() || '';
// Exclure les tickets terminés (support français ET anglais)
return statusCategory !== 'done' &&
statusCategory !== 'terminé' &&
!statusName.includes('done') &&
!statusName.includes('closed') &&
!statusName.includes('resolved') &&
!statusName.includes('complete') &&
!statusName.includes('fait') &&
!statusName.includes('clôturé') &&
!statusName.includes('cloturé') &&
!statusName.includes('en production') &&
!statusName.includes('finished') &&
!statusName.includes('delivered');
});
const assigneeWorkload = new Map<string, {
displayName: string;
todo: number;
inProgress: number;
review: number;
}>();
wipIssues.forEach(issue => {
const assignee = issue.assignee;
const status = issue.status?.name?.toLowerCase() || '';
const assigneeKey = assignee?.emailAddress || 'unassigned';
const displayName = assignee?.displayName || 'Non assigné';
if (!assigneeWorkload.has(assigneeKey)) {
assigneeWorkload.set(assigneeKey, {
displayName,
todo: 0,
inProgress: 0,
review: 0
});
}
const workload = assigneeWorkload.get(assigneeKey)!;
const statusCategory = issue.status?.category?.toLowerCase();
// Classification robuste français/anglais basée sur les catégories et noms Jira
if (statusCategory === 'indeterminate' ||
statusCategory === 'en cours' ||
status.includes('progress') ||
status.includes('en cours') ||
status.includes('developing') ||
status.includes('implementation')) {
workload.inProgress++;
} else if (status.includes('review') ||
status.includes('testing') ||
status.includes('validation') ||
status.includes('validating') ||
status.includes('ready for')) {
workload.review++;
} else if (statusCategory === 'new' ||
statusCategory === 'a faire' ||
status.includes('todo') ||
status.includes('to do') ||
status.includes('a faire') ||
status.includes('backlog') ||
status.includes('product backlog') ||
status.includes('ready to sprint') ||
status.includes('estimating') ||
status.includes('refinement') ||
status.includes('open') ||
status.includes('created')) {
workload.todo++;
} else {
// Fallback: si on ne peut pas classifier, mettre en "À faire"
workload.todo++;
}
});
const byAssignee: AssigneeWorkload[] = Array.from(assigneeWorkload.entries()).map(([assignee, workload]) => ({
assignee,
displayName: workload.displayName,
todoCount: workload.todo,
inProgressCount: workload.inProgress,
reviewCount: workload.review,
totalActive: workload.todo + workload.inProgress + workload.review
})).sort((a, b) => b.totalActive - a.totalActive);
return {
byStatus,
byAssignee
};
}
}