- 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`.
462 lines
16 KiB
TypeScript
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
|
|
};
|
|
}
|
|
}
|